infoalloggi-monorepo/apps/infoalloggi/src/components/annuncio_card.tsx

601 lines
18 KiB
TypeScript
Raw Normal View History

import {
BedDouble,
CalendarClock,
MapPin,
Maximize2,
Ruler,
Siren,
Star,
TrafficCone,
} from "lucide-react";
import Image from "next/image";
2025-08-28 18:27:07 +02:00
import Link from "next/link";
import { type RefObject, useEffect, useRef, useState } from "react";
import { useIntersection } from "react-use";
2025-08-28 18:27:07 +02:00
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "~/components/ui/carousel";
2025-08-04 17:45:44 +02:00
import {
2025-08-28 18:27:07 +02:00
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
2025-08-04 17:45:44 +02:00
} from "~/components/ui/dialog";
import { camereTesti, handleConsegna } from "~/lib/annuncio_details";
import { getStorageUrl } from "~/lib/storage_utils";
2025-08-28 18:27:07 +02:00
import { cn, formatCurrency } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import type { ImagesRefs } from "~/schemas/public/ImagesRefs";
import type { VideosRefs } from "~/schemas/public/VideosRefs";
import type { AnnuncioRicerca } from "~/server/controllers/annunci.controller";
import { Badge } from "./ui/badge";
import { Skeleton } from "./ui/skeleton";
import { VideoPlayer } from "./videoPlayer";
2025-08-04 17:45:44 +02:00
type CardAnnuncioProps = AnnuncioRicerca & {
2025-08-28 18:27:07 +02:00
className?: string;
noLink?: boolean;
onlyFirstImage?: boolean;
eager?: boolean;
};
export const LazyCardAnnuncio = (props: CardAnnuncioProps) => {
const { eager = false } = props;
const intersectionRef = useRef<HTMLDivElement>(null);
const hasRenderedRef = useRef(eager);
const [shouldRender, setShouldRender] = useState(eager);
const intersection = useIntersection(
intersectionRef as RefObject<HTMLElement>,
{
root: null,
rootMargin: "300px",
threshold: 0.01,
},
);
useEffect(() => {
if (
!hasRenderedRef.current &&
intersection &&
intersection.intersectionRatio > 0
) {
hasRenderedRef.current = true;
setShouldRender(true);
}
}, [intersection]);
return (
<div
className="min-h-[31.1rem]"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onKeyDown={(e) => e.stopPropagation()}
ref={intersectionRef}
role="button"
tabIndex={0}
>
{shouldRender ? <CardAnnuncio {...props} /> : <SkeletonCardAnnuncio />}
</div>
);
2025-08-04 17:45:44 +02:00
};
const SkeletonCardAnnuncio = () => {
return (
<div className="h-[31.1rem] rounded-md bg-secondary p-2 outline outline-neutral-300 hover:outline-neutral-600">
<div className="flex h-full flex-col gap-2 p-2">
<Skeleton className="h-64 w-full rounded-t-md" />
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-full rounded-md" />
<div className="flex flex-row justify-between gap-3 px-4">
<Skeleton className="h-10 w-full rounded-md" />
<Skeleton className="h-10 w-full rounded-md" />
</div>
<div className="flex h-34 flex-col justify-between">
<Skeleton className="h-20 w-full rounded-md" />
<div className="flex h-9 items-center justify-around gap-1.5">
<Skeleton className="size-full rounded-md" />
<Skeleton className="size-full rounded-md" />
<Skeleton className="size-full rounded-md" />
</div>
</div>
</div>
</div>
</div>
);
};
2025-08-28 10:29:04 +02:00
export const CardAnnuncio = ({
2025-08-28 18:27:07 +02:00
id,
codice,
prezzo,
titolo_it,
titolo_en,
2025-08-28 18:27:07 +02:00
mq,
comune,
provincia,
consegna,
numero_camere: camere,
2025-08-28 18:27:07 +02:00
tipo,
stato,
className,
videos,
media_updated_at,
images,
homepage,
noLink = false,
onlyFirstImage = false,
2025-08-04 17:45:44 +02:00
}: CardAnnuncioProps) => {
const { t, locale } = useTranslation();
const currentMonth = new Date().getMonth() + 1;
const isAvailableNow = consegna === 0 || consegna === currentMonth;
2025-08-04 17:45:44 +02:00
const Wrapper = ({ children }: { children: React.ReactNode }) => {
if (noLink) {
return <div className="flex h-full flex-col gap-2 p-2">{children}</div>;
}
return (
<Link
className="flex h-full flex-col gap-2 p-2"
href={`/annuncio/${codice}`}
>
{children}
</Link>
);
};
2025-08-28 18:27:07 +02:00
return (
<div
className={cn(
"relative h-[31.1rem] rounded-md bg-secondary outline outline-primary/60 hover:outline-primary dark:bg-primary dark:outline-primary",
2025-08-28 18:27:07 +02:00
className,
)}
id={`card-annuncio-${id}`}
>
<Wrapper>
<div
className={cn("group relative overflow-clip text-clip rounded-md")}
>
<div
className={cn(
"pointer-events-none absolute inset-0 z-20 size-full rounded-md",
homepage &&
(isAvailableNow
? "border-[.35rem] border-dasubito"
: "border-[.35rem] border-evidenza"),
stato === "Trattativa" && "border-[.35rem] border-trattativa",
)}
></div>
{stato !== "Trattativa" && homepage && (
<div className="absolute top-1 z-20 flex w-full justify-center px-2">
<Badge
className={cn(
2025-11-25 09:57:31 +01:00
"flex items-center gap-1.5 rounded-t-none px-1 py-0.5 font-semibold text-base text-white [&>svg]:size-4.5",
isAvailableNow
? "bg-dasubito"
: "bg-evidenza text-primary dark:text-accent",
)}
>
{isAvailableNow ? (
<>
<span>Libero Subito</span>
<Siren />
</>
) : (
<>
<span>In Evidenza</span>
<Star />
</>
)}
</Badge>
</div>
)}
2025-08-28 18:27:07 +02:00
{stato === "Trattativa" && (
<div className="absolute top-1 z-20 flex w-full justify-center px-2">
<Badge
className={cn(
2025-11-25 09:57:31 +01:00
"flex items-center gap-1.5 rounded-t-none bg-trattativa px-1 py-0.5 font-semibold text-base text-white [&>svg]:size-4.5",
)}
>
<span>In Trattativa</span>
<TrafficCone />
</Badge>
2025-08-28 18:27:07 +02:00
</div>
)}
{!onlyFirstImage ? (
<Carousel opts={{ loop: true }}>
<CarouselContent>
{images?.map((img, idx) => (
<CarouselItem key={`${img.img}key`}>
<Image
alt={t.card.alt_immagine}
blurDataURL={getStorageUrl({
storageId: img.thumb,
params: {
cacheKey: media_updated_at?.toISOString(),
media: "image",
},
})}
className={cn(
"h-64 w-full bg-[#e6e9ec] object-cover",
(stato === "Trattativa" || homepage) && "rounded-[7px]",
)}
height={1080}
priority={idx === 0}
src={getStorageUrl({
storageId: img.img,
params: {
cacheKey: media_updated_at?.toISOString(),
media: "image",
},
})}
width={1920}
/>
</CarouselItem>
))}
{videos?.map((video) => {
return (
<CarouselItem
className={cn("group relative")}
key={`videoplayer-${video}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<VideoPlayer
className="h-64 object-cover"
coverImage={getStorageUrl({
storageId: video.thumb,
params: {
cacheKey: media_updated_at?.toISOString(),
media: "image",
},
})}
key={`videoplayer-${video}`}
videoSrc={getStorageUrl({
storageId: video.video,
params: {
cacheKey: media_updated_at?.toISOString(),
media: "video",
},
})}
/>
</CarouselItem>
);
})}
</CarouselContent>
2025-08-04 17:45:44 +02:00
<div
className="block opacity-0 transition-all duration-200 group-hover:opacity-60 disabled:group-hover:opacity-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onKeyDown={(e) => e.stopPropagation()}
role="button"
tabIndex={0}
>
<CarouselPrevious
className="left-2 cursor-pointer"
disabled={!images || images.length === 1}
/>
<CarouselNext
className="right-2 cursor-pointer"
disabled={!images || images.length === 1}
/>
</div>
</Carousel>
) : (
<>
{images && images.length > 0 && images[0] ? (
<Image
alt={t.card.alt_immagine}
blurDataURL={getStorageUrl({
storageId: images[0].thumb,
params: {
cacheKey: media_updated_at?.toISOString(),
media: "image",
},
})}
className={"h-64 w-full bg-[#e6e9ec] object-cover"}
height={1080}
priority={true}
src={getStorageUrl({
storageId: images[0].img,
params: {
cacheKey: media_updated_at?.toISOString(),
media: "image",
},
})}
width={1920}
/>
) : (
<div className="flex h-64 w-full items-center justify-center bg-neutral-200 text-neutral-500"></div>
)}
</>
)}
2025-08-28 18:27:07 +02:00
</div>
<div className="flex h-full grow flex-col gap-2">
<Badge
className={cn(
"w-full font-semibold text-md text-white",
tipo === "Transitorio" && "bg-transitorio",
tipo === "Stabile" && "bg-stabile",
tipo === "Cessione" && "bg-blue-400",
tipo === "Vendita" && "bg-green-400",
)}
>
Affitto {tipo}
</Badge>
<div className="flex flex-row justify-between gap-3 px-2">
<div className="w-full text-center">
2025-08-28 18:27:07 +02:00
<span className="sr-only">{t.card.codice}</span>
2025-08-04 17:45:44 +02:00
<span className="line-clamp-1 font-semibold text-destructive text-xl">
2025-08-28 18:27:07 +02:00
Cod: {codice}
</span>
</div>
<div className="w-full text-center text-primary dark:text-accent">
2025-08-28 18:27:07 +02:00
<span className="sr-only">{t.card.prezzo}</span>
2025-08-04 17:45:44 +02:00
2025-10-10 16:18:43 +02:00
<span className="font-semibold text-xl">
2025-08-28 18:27:07 +02:00
{formatCurrency(prezzo / 1e2)}
</span>
</div>
</div>
<div className="flex h-full grow flex-col justify-between">
<div className="px-4 text-center text-primary dark:text-accent">
<span className="sr-only">{t.card.titolo}</span>
2025-08-04 17:45:44 +02:00
2026-05-14 16:27:23 +02:00
<span className="line-clamp-3 text-ellipsis font-medium text-base">
{locale === "it" ? titolo_it : titolo_en}
</span>
</div>
2025-08-04 17:45:44 +02:00
<div className="text-center text-primary dark:text-accent">
<span className="sr-only">{t.card.indirizzo}</span>
<MapPin className="mr-1 mb-1 inline-block size-4" />
<span className="font-medium text-sm">
{comune && provincia && `${comune} (${provincia})`}
</span>
</div>
<div className="flex h-9 items-center justify-around gap-1.5 text-primary text-xs">
<div className="flex h-full w-full items-center justify-center gap-1 rounded-md bg-primary-foreground">
<Ruler className="size-4" />
2025-08-04 17:45:44 +02:00
<div>
<p className="font-medium">
{mq ? Number(mq).toLocaleString("it-IT") : "--"}{" "}
<span>
m<sup>2</sup>
</span>
</p>
</div>
</div>
<div className="flex h-full w-full items-center justify-center gap-1 rounded-md bg-primary-foreground">
<BedDouble className="size-4" />
2025-08-04 17:45:44 +02:00
<div>
<p className="font-medium">
{camereTesti({ camere: camere, testi: t.card })}
</p>
</div>
</div>
<div className="flex h-full w-full items-center justify-center gap-1 rounded-md bg-primary-foreground">
<CalendarClock className="size-4" />
2025-08-04 17:45:44 +02:00
<div>
<p className="truncate font-medium">
{handleConsegna({
aggiornamento: t.card.in_aggiornamento,
consegna: consegna,
consegna_da: t.card.consegna_da,
mesi: t.parametri.mesi,
subito: t.card.consegna_subito,
})}
</p>
</div>
</div>
2025-08-28 18:27:07 +02:00
</div>
</div>
</div>
</Wrapper>
2025-08-28 18:27:07 +02:00
</div>
);
2025-08-04 17:45:44 +02:00
};
export const CarouselAnnuncio = ({
2025-08-28 18:27:07 +02:00
single,
immagini,
videos,
updated_at,
2025-08-04 17:45:44 +02:00
}: {
2025-08-28 18:27:07 +02:00
single?: boolean;
immagini: Pick<ImagesRefs, "img" | "thumb">[];
videos: Pick<VideosRefs, "video" | "thumb">[];
updated_at: Date | null;
2025-08-04 17:45:44 +02:00
}) => {
2025-08-28 18:27:07 +02:00
const [openModal, setOpenModal] = useState(false);
const [idxModal, setIdxModal] = useState(0);
function handleOpenModal(position: number) {
setIdxModal(position);
setOpenModal(true);
}
return (
<>
2025-10-10 16:18:43 +02:00
<div className="mx-auto my-4 w-full text-clip rounded-md">
2025-08-28 18:27:07 +02:00
<div className="relative">
2025-08-29 16:18:32 +02:00
<Carousel opts={{ align: "start", loop: true }}>
2025-08-28 18:27:07 +02:00
<CarouselContent>
{immagini?.map((img, idx) => (
<CarouselItem
className={cn(
2025-10-20 11:35:48 +02:00
"group relative flex items-center justify-center",
2025-08-28 18:27:07 +02:00
immagini && immagini.length === 1
? ""
: !single && "md:basis-1/2 lg:basis-1/3",
)}
key={`${img.img}-cF`}
2025-08-28 18:27:07 +02:00
>
<Image
alt={img.img}
2025-08-28 18:27:07 +02:00
className={cn(
"aspect-square max-h-92 w-full rounded-md bg-[#e6e9ec] object-cover sm:max-h-80 xl:max-h-100",
2025-08-28 18:27:07 +02:00
immagini.length === 1 && "object-contain",
)}
2025-08-29 16:18:32 +02:00
height={400}
onClick={() => handleOpenModal(idx)}
priority={idx === 0}
src={getStorageUrl({
storageId: img.img,
params: {
cacheKey: updated_at?.toISOString(),
media: "image",
},
})}
2025-08-29 16:18:32 +02:00
width={800}
2025-08-28 18:27:07 +02:00
/>
<Maximize2
className="absolute right-2 bottom-2 size-7 origin-bottom-right cursor-pointer rounded-md bg-muted-foreground/60 stroke-2 stroke-muted p-1 transition-all duration-300 ease-in-out hover:scale-150 group-hover:opacity-100 sm:opacity-0"
2025-08-29 16:18:32 +02:00
onClick={() => handleOpenModal(idx)}
2025-08-28 18:27:07 +02:00
/>
</CarouselItem>
))}
{videos?.map((video, idx) => {
return (
<CarouselItem
className={cn(
"group relative",
immagini && immagini.length === 1
? ""
: !single && "md:basis-1/2 lg:basis-1/3",
)}
key={`videoplayer-${video.video}`}
>
<VideoPlayer
className="aspect-square max-h-92 object-cover sm:max-h-80 xl:max-h-100"
coverImage={getStorageUrl({
storageId: video.thumb,
params: {
cacheKey: updated_at?.toISOString(),
media: "image",
},
})}
key={`videoplayer-${
// biome-ignore lint/suspicious/noArrayIndexKey: <ok>
idx
}-${openModal}`}
videoSrc={getStorageUrl({
storageId: video.video,
params: {
cacheKey: updated_at?.toISOString(),
media: "video",
},
})}
/>
<Maximize2
className="absolute right-2 bottom-2 size-7 origin-bottom-right cursor-pointer rounded-md bg-muted-foreground/60 stroke-2 stroke-muted p-1 transition-all duration-300 ease-in-out hover:scale-150 group-hover:opacity-100 sm:opacity-0"
onClick={() =>
handleOpenModal(immagini?.length || 0 + idx)
}
/>
</CarouselItem>
);
})}
2025-08-28 18:27:07 +02:00
</CarouselContent>
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
<CarouselPrevious
className="left-2 cursor-pointer opacity-60 disabled:opacity-0 dark:border-muted-foreground dark:bg-muted-foreground dark:text-muted hover:dark:bg-muted-foreground/80"
2025-08-28 18:27:07 +02:00
disabled={!immagini || immagini.length === 1}
2025-08-29 16:18:32 +02:00
type="button"
2025-08-28 18:27:07 +02:00
/>
<CarouselNext
className="right-2 cursor-pointer opacity-60 disabled:opacity-0 dark:border-muted-foreground dark:bg-muted-foreground dark:text-muted hover:dark:bg-muted-foreground/80"
2025-08-28 18:27:07 +02:00
disabled={!immagini || immagini.length === 1}
2025-08-29 16:18:32 +02:00
type="button"
2025-08-28 18:27:07 +02:00
/>
</Carousel>
</div>
</div>
2025-08-29 16:18:32 +02:00
<Dialog onOpenChange={setOpenModal} open={openModal}>
<DialogContent className="flex h-full w-full max-w-full items-center justify-center border-transparent border-none bg-background p-0 md:max-w-[80vw] md:p-6 [&_#dialog-close>svg]:size-8">
2025-08-28 18:27:07 +02:00
<DialogHeader className="absolute top-0 right-0 left-0 z-10">
<DialogTitle className="sr-only">Image Dialog</DialogTitle>
<DialogDescription className="sr-only">Immagine</DialogDescription>
</DialogHeader>
<Carousel opts={{ loop: true, startIndex: idxModal }}>
<CarouselContent>
{immagini?.map((img, idx) => (
<CarouselItem key={`${img.img}-cD`}>
<Image
2025-08-28 18:27:07 +02:00
alt={`carousel-img-${idx}`}
className="mx-auto h-[90vh] w-full rounded-md bg-[#e6e9ec] object-contain sm:w-[80vw]"
2025-08-28 18:27:07 +02:00
height={1080}
src={getStorageUrl({
storageId: img.img,
params: {
cacheKey: updated_at?.toISOString(),
media: "image",
},
})}
2025-08-29 16:18:32 +02:00
width={1920}
2025-08-28 18:27:07 +02:00
/>
</CarouselItem>
))}
{videos?.map((video) => {
return (
<CarouselItem
className="relative flex items-center justify-center"
key={`carousel-videoplayer-${video.video}`}
>
<VideoPlayer
2025-10-10 16:18:43 +02:00
className="absolute mx-auto h-[90vh] w-full max-w-fit rounded-md object-contain"
coverImage={getStorageUrl({
storageId: video.thumb,
params: {
cacheKey: updated_at?.toISOString(),
media: "image",
},
})}
videoSrc={getStorageUrl({
storageId: video.video,
params: {
cacheKey: updated_at?.toISOString(),
media: "video",
},
})}
/>
</CarouselItem>
);
})}
2025-08-28 18:27:07 +02:00
</CarouselContent>
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
<CarouselPrevious
className="left-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1}
2025-08-29 16:18:32 +02:00
type="button"
2025-08-28 18:27:07 +02:00
/>
<CarouselNext
className="right-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1}
2025-08-29 16:18:32 +02:00
type="button"
2025-08-28 18:27:07 +02:00
/>
</Carousel>
</DialogContent>
</Dialog>
</>
);
2025-08-04 17:45:44 +02:00
};