Better court configuration
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>PlayZer</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
166
apps/frontend/src/components/ui/dialog.tsx
Normal file
166
apps/frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@@ -9,6 +9,14 @@ import {
|
|||||||
} from '@repo/api-contract'
|
} from '@repo/api-contract'
|
||||||
import { Controller, useFieldArray, useForm } from 'react-hook-form'
|
import { Controller, useFieldArray, useForm } from 'react-hook-form'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
@@ -134,16 +142,22 @@ function createDefaultValues(): CreateCourtInput {
|
|||||||
type CourtFormSectionProps = {
|
type CourtFormSectionProps = {
|
||||||
complexId: string | null
|
complexId: string | null
|
||||||
editingCourt: Court | null
|
editingCourt: Court | null
|
||||||
|
initialDraft: CreateCourtInput | null
|
||||||
onCancelEdit: () => void
|
onCancelEdit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function CourtFormSection({
|
function CourtFormSection({
|
||||||
complexId,
|
complexId,
|
||||||
editingCourt,
|
editingCourt,
|
||||||
|
initialDraft,
|
||||||
onCancelEdit,
|
onCancelEdit,
|
||||||
}: CourtFormSectionProps) {
|
}: CourtFormSectionProps) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [formError, setFormError] = useState<string | null>(null)
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
const [isWeekModalOpen, setIsWeekModalOpen] = useState(false)
|
||||||
|
const [weekDefaultStartTime, setWeekDefaultStartTime] = useState('08:00')
|
||||||
|
const [weekDefaultEndTime, setWeekDefaultEndTime] = useState('22:00')
|
||||||
|
const [weekModalError, setWeekModalError] = useState<string | null>(null)
|
||||||
|
|
||||||
const sportsQuery = useQuery({
|
const sportsQuery = useQuery({
|
||||||
queryKey: ['sports'],
|
queryKey: ['sports'],
|
||||||
@@ -201,11 +215,7 @@ function CourtFormSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editingCourt) {
|
if (editingCourt) {
|
||||||
form.reset(createDefaultValues())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
name: editingCourt.name,
|
name: editingCourt.name,
|
||||||
sportId: editingCourt.sportId,
|
sportId: editingCourt.sportId,
|
||||||
@@ -217,7 +227,16 @@ function CourtFormSection({
|
|||||||
endTime: slot.endTime,
|
endTime: slot.endTime,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}, [editingCourt, form])
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialDraft) {
|
||||||
|
form.reset(initialDraft)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset(createDefaultValues())
|
||||||
|
}, [editingCourt, form, initialDraft])
|
||||||
|
|
||||||
const title = editingCourt ? 'Editar cancha' : 'Nueva cancha'
|
const title = editingCourt ? 'Editar cancha' : 'Nueva cancha'
|
||||||
const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha'
|
const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha'
|
||||||
@@ -317,23 +336,10 @@ function CourtFormSection({
|
|||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const availability = form.getValues('availability')
|
const availability = form.getValues('availability')
|
||||||
const baseStartTime = availability[0]?.startTime ?? '08:00'
|
setWeekDefaultStartTime(availability[0]?.startTime ?? '08:00')
|
||||||
const baseEndTime = availability[0]?.endTime ?? '22:00'
|
setWeekDefaultEndTime(availability[0]?.endTime ?? '22:00')
|
||||||
const existingDays = new Set(
|
setWeekModalError(null)
|
||||||
availability.map((item) => item.dayOfWeek),
|
setIsWeekModalOpen(true)
|
||||||
)
|
|
||||||
|
|
||||||
const missingDays = DAY_OPTIONS.filter(
|
|
||||||
(option) => !existingDays.has(option.value),
|
|
||||||
).map((option) => ({
|
|
||||||
dayOfWeek: option.value,
|
|
||||||
startTime: baseStartTime,
|
|
||||||
endTime: baseEndTime,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (missingDays.length > 0) {
|
|
||||||
availabilityFieldArray.append(missingDays)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Agregar semana completa
|
Agregar semana completa
|
||||||
@@ -442,6 +448,101 @@ function CourtFormSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isWeekModalOpen}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
setIsWeekModalOpen(nextOpen)
|
||||||
|
if (!nextOpen) {
|
||||||
|
setWeekModalError(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Horario por defecto</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Ingresa el horario para completar los días faltantes de la semana.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="week-start-time">Desde</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="week-start-time"
|
||||||
|
type="time"
|
||||||
|
value={weekDefaultStartTime}
|
||||||
|
onChange={(event) => {
|
||||||
|
setWeekDefaultStartTime(event.target.value)
|
||||||
|
setWeekModalError(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="week-end-time">Hasta</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="week-end-time"
|
||||||
|
type="time"
|
||||||
|
value={weekDefaultEndTime}
|
||||||
|
onChange={(event) => {
|
||||||
|
setWeekDefaultEndTime(event.target.value)
|
||||||
|
setWeekModalError(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weekModalError && <p className="text-sm text-destructive">{weekModalError}</p>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsWeekModalOpen(false)
|
||||||
|
setWeekModalError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (weekDefaultStartTime >= weekDefaultEndTime) {
|
||||||
|
setWeekModalError(
|
||||||
|
'El horario de inicio debe ser menor al horario de fin.',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = form.getValues('availability')
|
||||||
|
const existingDays = new Set(
|
||||||
|
availability.map((item) => item.dayOfWeek),
|
||||||
|
)
|
||||||
|
|
||||||
|
const missingDays = DAY_OPTIONS.filter(
|
||||||
|
(option) => !existingDays.has(option.value),
|
||||||
|
).map((option) => ({
|
||||||
|
dayOfWeek: option.value,
|
||||||
|
startTime: weekDefaultStartTime,
|
||||||
|
endTime: weekDefaultEndTime,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (missingDays.length > 0) {
|
||||||
|
availabilityFieldArray.append(missingDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsWeekModalOpen(false)
|
||||||
|
setWeekModalError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Agregar días faltantes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -452,6 +553,7 @@ type ComplexCourtsPageProps = {
|
|||||||
|
|
||||||
export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
||||||
const [editingCourt, setEditingCourt] = useState<Court | null>(null)
|
const [editingCourt, setEditingCourt] = useState<Court | null>(null)
|
||||||
|
const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentComplexSlug(complexSlug)
|
setCurrentComplexSlug(complexSlug)
|
||||||
@@ -484,6 +586,7 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
|||||||
<CourtFormSection
|
<CourtFormSection
|
||||||
complexId={complexId}
|
complexId={complexId}
|
||||||
editingCourt={editingCourt}
|
editingCourt={editingCourt}
|
||||||
|
initialDraft={initialDraft}
|
||||||
onCancelEdit={() => {
|
onCancelEdit={() => {
|
||||||
setEditingCourt(null)
|
setEditingCourt(null)
|
||||||
}}
|
}}
|
||||||
@@ -519,10 +622,35 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
|||||||
{formatCurrency(court.basePrice)}
|
{formatCurrency(court.basePrice)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setEditingCourt(null)
|
||||||
|
setInitialDraft({
|
||||||
|
name: `${court.name} - Copy`,
|
||||||
|
sportId: court.sportId,
|
||||||
|
slotDurationMinutes: court.slotDurationMinutes,
|
||||||
|
basePrice: court.basePrice,
|
||||||
|
availability: court.availability.map((slot) => ({
|
||||||
|
dayOfWeek: slot.dayOfWeek,
|
||||||
|
startTime: slot.startTime,
|
||||||
|
endTime: slot.endTime,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Duplicar cancha
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={() => {
|
||||||
|
setInitialDraft(null)
|
||||||
setEditingCourt(court)
|
setEditingCourt(court)
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}}
|
}}
|
||||||
@@ -530,6 +658,7 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
|||||||
Editar
|
Editar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
{summarizeAvailability(court.availability).map((summary) => (
|
{summarizeAvailability(court.availability).map((summary) => (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router'
|
import { Link, Outlet, useNavigate } from '@tanstack/react-router'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { LogOut, Menu, UserRound } from 'lucide-react'
|
import { LogOut, Menu, UserRound } from 'lucide-react'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
@@ -44,6 +44,17 @@ export function RootLayout() {
|
|||||||
}
|
}
|
||||||
}, [currentComplexSlug, myComplexesQuery.data])
|
}, [currentComplexSlug, myComplexesQuery.data])
|
||||||
|
|
||||||
|
const currentComplexName = useMemo(() => {
|
||||||
|
const complexes = myComplexesQuery.data ?? []
|
||||||
|
if (complexes.length === 0) return 'Mi complejo'
|
||||||
|
|
||||||
|
const selected = complexes.find(
|
||||||
|
(complex) => complex.complexSlug === currentComplexSlug,
|
||||||
|
)
|
||||||
|
|
||||||
|
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo'
|
||||||
|
}, [currentComplexSlug, myComplexesQuery.data])
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await signOut()
|
await signOut()
|
||||||
await navigate({ to: '/login' })
|
await navigate({ to: '/login' })
|
||||||
@@ -53,7 +64,7 @@ export function RootLayout() {
|
|||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4">
|
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<h1 className="text-sm font-semibold">TanStack Router</h1>
|
<h1 className="text-sm font-semibold">{currentComplexName}</h1>
|
||||||
<nav className="hidden items-center gap-3 text-sm md:flex">
|
<nav className="hidden items-center gap-3 text-sm md:flex">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
|
|||||||
Reference in New Issue
Block a user