add car reservation feature
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
type MarketplaceHomepageConfig,
|
||||
type MarketplaceHomepageSectionType,
|
||||
} from '@rentaldrivego/types'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
@@ -16,7 +17,7 @@ export default function HomeContent() {
|
||||
const [content, setContent] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
||||
const dict = content[language]
|
||||
const sections = resolveMarketplaceHomepageSections(dict.sections)
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
|
||||
const starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY¤cy=MAD`
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import WorkspaceFrame from '@/components/WorkspaceFrame'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function CompanyWorkspacePage() {
|
||||
return <WorkspaceFrame target="dashboard" />
|
||||
const requestHeaders = headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
|
||||
redirect(`${dashboardUrl}/dashboard`)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ interface Vehicle {
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
availability?: boolean | null
|
||||
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
nextAvailableAt?: string | null
|
||||
company: {
|
||||
slug: string
|
||||
brand: {
|
||||
@@ -27,8 +29,14 @@ interface Dict {
|
||||
rentalCompany: string
|
||||
checkAvailability: string
|
||||
available: string
|
||||
reserved: string
|
||||
maintenance: string
|
||||
unavailableStatus: string
|
||||
from: string
|
||||
bookNow: string
|
||||
availableFrom: string
|
||||
reserveAfterDate: string
|
||||
unavailableNoDate: string
|
||||
details: string
|
||||
vehicleUnavailable: string
|
||||
listings: string
|
||||
@@ -61,13 +69,14 @@ function daysBetween(start: string, end: string) {
|
||||
return Math.max(1, Math.ceil(ms / 86_400_000))
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
const tomorrow = () => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 1)
|
||||
return d.toISOString().slice(0, 10)
|
||||
function formatAvailabilityDate(value?: string | null) {
|
||||
if (!value) return null
|
||||
return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
const tomorrow = () => new Date(Date.now() + 86_400_000).toISOString().slice(0, 10)
|
||||
|
||||
export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehicle[]; dict: Dict }) {
|
||||
const [selected, setSelected] = useState<Vehicle | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
@@ -83,10 +92,12 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
function openModal(vehicle: Vehicle) {
|
||||
const minStart = vehicle.nextAvailableAt ? new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10) : today()
|
||||
const minEnd = new Date(new Date(minStart).getTime() + 86_400_000).toISOString().slice(0, 10)
|
||||
setSelected(vehicle)
|
||||
setStatus('idle')
|
||||
setErrorMsg('')
|
||||
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: today(), endDate: tomorrow(), notes: '' })
|
||||
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: minStart, endDate: minEnd, notes: '' })
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@@ -97,12 +108,25 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
|
||||
function set(field: keyof typeof form) {
|
||||
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm((prev) => ({ ...prev, [field]: e.target.value }))
|
||||
setForm((prev) => {
|
||||
if (field !== 'startDate') return { ...prev, [field]: e.target.value }
|
||||
const nextStart = e.target.value
|
||||
const nextEnd = !prev.endDate || prev.endDate <= nextStart
|
||||
? new Date(new Date(nextStart).getTime() + 86_400_000).toISOString().slice(0, 10)
|
||||
: prev.endDate
|
||||
return { ...prev, startDate: nextStart, endDate: nextEnd }
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!selected) return
|
||||
const minStart = selected.nextAvailableAt ? new Date(selected.nextAvailableAt).toISOString().slice(0, 10) : today()
|
||||
if (form.startDate < minStart) {
|
||||
setStatus('error')
|
||||
setErrorMsg(`${dict.reserveAfterDate} ${formatAvailabilityDate(selected.nextAvailableAt) ?? minStart}.`)
|
||||
return
|
||||
}
|
||||
setStatus('submitting')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
@@ -121,7 +145,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
} catch (err: any) {
|
||||
setStatus('error')
|
||||
if (err?.code === 'unavailable') {
|
||||
setErrorMsg(dict.errorUnavailable)
|
||||
setErrorMsg(err?.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(err.nextAvailableAt)}.` : dict.errorUnavailable)
|
||||
} else {
|
||||
setErrorMsg(err?.message ?? dict.errorGeneric)
|
||||
}
|
||||
@@ -131,6 +155,14 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
const days = form.startDate && form.endDate ? daysBetween(form.startDate, form.endDate) : 0
|
||||
const estimatedTotal = selected ? selected.dailyRate * days : 0
|
||||
|
||||
function getStatusCopy(vehicle: Vehicle) {
|
||||
if (vehicle.availabilityStatus === 'RESERVED') return dict.reserved
|
||||
if (vehicle.availabilityStatus === 'MAINTENANCE') return dict.maintenance
|
||||
if (vehicle.availabilityStatus === 'UNAVAILABLE') return dict.unavailableStatus
|
||||
if (vehicle.availability === false) return dict.checkAvailability
|
||||
return dict.available
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
@@ -155,9 +187,14 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
<p className="mt-1 text-sm text-stone-500">{vehicle.year} · {vehicle.category}</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${vehicle.availability === false ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'}`}>
|
||||
{vehicle.availability === false ? dict.checkAvailability : dict.available}
|
||||
{getStatusCopy(vehicle)}
|
||||
</span>
|
||||
</div>
|
||||
{vehicle.availability === false && (
|
||||
<p className="mt-3 text-sm text-stone-500">
|
||||
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${formatAvailabilityDate(vehicle.nextAvailableAt)}` : dict.unavailableNoDate}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-500">{dict.from}</p>
|
||||
@@ -217,6 +254,11 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
<h3 className="font-semibold text-stone-900">{dict.reserveVehicle}</h3>
|
||||
{selected.availability === false && (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{selected.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(selected.nextAvailableAt)}.` : dict.unavailableNoDate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -225,7 +267,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
min={today()}
|
||||
min={selected.nextAvailableAt ? new Date(selected.nextAvailableAt).toISOString().slice(0, 10) : today()}
|
||||
value={form.startDate}
|
||||
onChange={set('startDate')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
|
||||
@@ -16,7 +16,7 @@ interface CompanyProfile {
|
||||
whatsappNumber: string | null
|
||||
marketplaceRating: number | null
|
||||
} | null
|
||||
vehicles: Array<{ id: string; make: string; model: string; dailyRate: number; photos: string[]; category: string }>
|
||||
vehicles: Array<{ id: string; make: string; model: string; dailyRate: number; photos: string[]; category: string; availability: boolean; nextAvailableAt: string | null }>
|
||||
offers: Array<{ id: string; title: string; discountValue: number; validUntil: string }>
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
||||
noOffers: 'No active offers right now.',
|
||||
fleet: 'Fleet',
|
||||
viewVehicle: 'View vehicle',
|
||||
availableFrom: 'Available from',
|
||||
unavailableNoDate: 'Temporarily unavailable for new reservations.',
|
||||
},
|
||||
fr: {
|
||||
unavailable: 'Marketplace indisponible',
|
||||
@@ -54,6 +56,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
||||
noOffers: 'Aucune offre active pour le moment.',
|
||||
fleet: 'Flotte',
|
||||
viewVehicle: 'Voir le véhicule',
|
||||
availableFrom: 'Disponible à partir du',
|
||||
unavailableNoDate: 'Temporairement indisponible pour les nouvelles réservations.',
|
||||
},
|
||||
ar: {
|
||||
unavailable: 'السوق غير متاح',
|
||||
@@ -70,6 +74,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
||||
noOffers: 'لا توجد عروض نشطة حالياً.',
|
||||
fleet: 'الأسطول',
|
||||
viewVehicle: 'عرض السيارة',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
unavailableNoDate: 'غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
},
|
||||
}[language]
|
||||
const company = await marketplaceFetchOrDefault<CompanyProfile | null>(`/marketplace/${params.slug}`, null)
|
||||
@@ -133,6 +139,11 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
||||
<div className="p-5">
|
||||
<h3 className="text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">{vehicle.category}</p>
|
||||
{!vehicle.availability && (
|
||||
<p className="mt-2 text-sm text-stone-500">
|
||||
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}` : dict.unavailableNoDate}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex items-end justify-between">
|
||||
<p className="text-xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
|
||||
<Link href={`/explore/${params.slug}/vehicles/${vehicle.id}`} className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.viewVehicle}</Link>
|
||||
|
||||
@@ -15,6 +15,9 @@ interface VehicleDetail {
|
||||
features: string[]
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
availability: boolean
|
||||
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
nextAvailableAt: string | null
|
||||
company: {
|
||||
brand: {
|
||||
displayName: string
|
||||
@@ -38,6 +41,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
perDay: '/ day',
|
||||
continueTo: 'Continue to',
|
||||
bookingSite: 'booking site',
|
||||
availableFrom: 'Available from',
|
||||
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||
},
|
||||
fr: {
|
||||
unavailable: 'Marketplace indisponible',
|
||||
@@ -49,6 +54,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
perDay: '/ jour',
|
||||
continueTo: 'Continuer vers le site de réservation de',
|
||||
bookingSite: '',
|
||||
availableFrom: 'Disponible à partir du',
|
||||
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
|
||||
},
|
||||
ar: {
|
||||
unavailable: 'السوق غير متاح',
|
||||
@@ -60,6 +67,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
perDay: '/ يوم',
|
||||
continueTo: 'الانتقال إلى موقع حجز',
|
||||
bookingSite: '',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
},
|
||||
}[language]
|
||||
const vehicle = await marketplaceFetchOrDefault<VehicleDetail | null>(`/marketplace/${params.slug}/vehicles/${params.id}`, null)
|
||||
@@ -82,6 +91,9 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
}
|
||||
|
||||
const destination = `https://${params.slug}.RentalDriveGo.com/book?vehicleId=${vehicle.id}&ref=marketplace`
|
||||
const availabilityDate = vehicle.nextAvailableAt
|
||||
? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: null
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
@@ -102,6 +114,11 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
<h1 className="mt-3 text-4xl font-black text-stone-900">{vehicle.make} {vehicle.model}</h1>
|
||||
<p className="mt-3 text-stone-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
|
||||
<p className="mt-6 text-3xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-stone-500"> {dict.perDay}</span></p>
|
||||
{!vehicle.availability && (
|
||||
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{availabilityDate ? `${dict.availableFrom} ${availabilityDate}` : dict.unavailableNoDate}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{vehicle.features.map((feature) => (
|
||||
<span key={feature} className="rounded-full bg-stone-100 px-3 py-1 text-xs font-medium text-stone-700">{feature}</span>
|
||||
|
||||
@@ -12,6 +12,8 @@ interface Vehicle {
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
availability?: boolean | null
|
||||
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
nextAvailableAt?: string | null
|
||||
company: {
|
||||
slug: string
|
||||
brand: {
|
||||
@@ -72,8 +74,14 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
rentalCompany: 'Rental company',
|
||||
checkAvailability: 'Check availability',
|
||||
available: 'Available',
|
||||
reserved: 'Reserved',
|
||||
maintenance: 'In maintenance',
|
||||
unavailableStatus: 'Unavailable',
|
||||
from: 'From',
|
||||
bookNow: 'Reserve',
|
||||
availableFrom: 'Available from',
|
||||
reserveAfterDate: 'This vehicle can only be reserved starting',
|
||||
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||
details: 'Details',
|
||||
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
|
||||
browseByCategory: 'Browse by category',
|
||||
@@ -123,8 +131,14 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
rentalCompany: 'Société de location',
|
||||
checkAvailability: 'Vérifier la disponibilité',
|
||||
available: 'Disponible',
|
||||
reserved: 'Réservé',
|
||||
maintenance: 'En maintenance',
|
||||
unavailableStatus: 'Indisponible',
|
||||
from: 'À partir de',
|
||||
bookNow: 'Réserver',
|
||||
availableFrom: 'Disponible à partir du',
|
||||
reserveAfterDate: 'Ce véhicule ne peut être réservé qu’à partir du',
|
||||
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
|
||||
details: 'Détails',
|
||||
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
|
||||
browseByCategory: 'Parcourir par catégorie',
|
||||
@@ -173,8 +187,14 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
rentalCompany: 'شركة تأجير',
|
||||
checkAvailability: 'تحقق من التوفر',
|
||||
available: 'متاح',
|
||||
reserved: 'محجوز',
|
||||
maintenance: 'قيد الصيانة',
|
||||
unavailableStatus: 'غير متاح',
|
||||
from: 'ابتداءً من',
|
||||
bookNow: 'احجز',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
reserveAfterDate: 'يمكن حجز هذه السيارة ابتداءً من',
|
||||
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
details: 'التفاصيل',
|
||||
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
|
||||
browseByCategory: 'تصفح حسب الفئة',
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import WorkspaceFrame from '@/components/WorkspaceFrame'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function PlatformOperationsPage() {
|
||||
return <WorkspaceFrame target="admin" />
|
||||
const requestHeaders = headers()
|
||||
const adminUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
|
||||
redirect(`${adminUrl}/dashboard`)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
type Currency = 'MAD' | 'USD' | 'EUR'
|
||||
type Billing = 'monthly' | 'annual'
|
||||
@@ -124,7 +125,7 @@ export default function PricingClient() {
|
||||
const dict = copy[language]
|
||||
const [currency, setCurrency] = useState<Currency>('MAD')
|
||||
const [billing, setBilling] = useState<Billing>('monthly')
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
|
||||
|
||||
const savings = annualSavingsPct('STARTER', currency) // same % for all plans
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function RenterSignInPage() {
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
const requestHeaders = headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
redirect(`${dashboardUrl}/sign-in`)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function RenterSignUpPage() {
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
const requestHeaders = headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
redirect(`${dashboardUrl}/sign-in`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user