infoalloggi-monorepo/apps/infoalloggi/src/pages/annuncio/[cod].tsx

774 lines
21 KiB
TypeScript
Raw Normal View History

2025-08-04 17:45:44 +02:00
import {
2025-08-28 18:27:07 +02:00
Armchair,
ArrowUp01,
BadgeEuro,
2025-08-28 18:27:07 +02:00
Bath,
BedDouble,
Car,
Clock,
Copy,
Edit,
ExternalLink,
Printer,
2025-08-28 18:27:07 +02:00
Ruler,
Share2,
TrafficCone,
2025-08-04 17:45:44 +02:00
} from "lucide-react";
import type { GetStaticPaths, GetStaticPropsContext, NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
2025-08-28 18:27:07 +02:00
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { CarouselAnnuncio } from "~/components/annuncio_card";
import { AnnuncioInteractions } from "~/components/annuncio-interactions/annuncio_interactions";
import { TouchProvider } from "~/components/custom_ui/HybridTooltip";
import Input from "~/components/custom_ui/input";
import { ComeFunziona } from "~/components/expand_guida";
2025-08-28 18:27:07 +02:00
import FailedAnnuncioLoading from "~/components/failed-loading";
2025-08-04 17:45:44 +02:00
import { IconMatrix, type IconType } from "~/components/IconComponents";
2025-08-28 18:27:07 +02:00
import InformationBubble from "~/components/InformationBubble";
2025-08-22 16:27:58 +02:00
import { LoadingPage } from "~/components/loading";
2025-08-04 17:45:44 +02:00
import { MappaDialogFullscreen } from "~/components/MapDialog";
import { PricingComponent } from "~/components/prezzi";
2025-08-28 18:27:07 +02:00
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "~/components/ui/accordion";
import { Button } from "~/components/ui/button";
2025-08-04 17:45:44 +02:00
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
2025-08-04 17:45:44 +02:00
import {
2025-08-28 18:27:07 +02:00
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
2025-08-04 17:45:44 +02:00
} from "~/components/ui/dialog";
import { Label } from "~/components/ui/label";
import { VideoPlayer } from "~/components/videoPlayer";
import { env } from "~/env";
2025-08-28 18:27:07 +02:00
import {
type CaratteristicheFiltered,
filteredCaratteristiche,
} from "~/hooks/schedaAnnuncioUtils";
import { useStaleImageReload } from "~/hooks/staleImage";
2025-08-28 18:27:07 +02:00
import { useMediaQuery } from "~/hooks/use-media-query";
import { handleConsegna } from "~/lib/annuncio_details";
import { replaceWithBr } from "~/lib/newlineToBr";
import { getStorageUrl } from "~/lib/storage_utils";
2025-08-28 18:27:07 +02:00
import { cn } from "~/lib/utils";
import { AnnuncioContext } from "~/providers/AnnuncioProvider";
import { useTranslation } from "~/providers/I18nProvider";
import { useSession } from "~/providers/SessionProvider";
import type { Annunci } from "~/schemas/public/Annunci";
2025-08-28 18:27:07 +02:00
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import type { VideosRefs } from "~/schemas/public/VideosRefs";
2025-08-28 18:27:07 +02:00
import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { api } from "~/utils/api";
2025-08-04 17:45:44 +02:00
type AnnuncioProps = {
2025-08-28 18:27:07 +02:00
cod: string;
flag: string;
meta: {
ogImage: string;
title: string;
description: string;
ogUrl: string;
} | null;
2025-08-04 17:45:44 +02:00
};
const AnnuncioDettaglio: NextPage<AnnuncioProps> = ({
2025-08-28 18:27:07 +02:00
cod,
flag,
meta,
2025-08-04 17:45:44 +02:00
}: AnnuncioProps) => {
2025-08-28 18:27:07 +02:00
return (
<main>
<Head>
<title>{`${cod} | ${meta?.title}`}</title>
2025-08-29 16:18:32 +02:00
<meta content={meta?.description} property="description" />
<meta content={meta?.ogUrl} property="og:url" />
<meta content="website" property="og:type" />
<meta content={`${cod} | ${meta?.title}`} property="og:title" />
<meta content={meta?.description} property="og:description" />
<meta content={meta?.ogImage} property="og:image" />
<meta content="1200" property="og:image:width" />
<meta content="630" property="og:image:height" />
2025-08-28 18:27:07 +02:00
<meta
content={`${env.NEXT_PUBLIC_BASE_URL}/Infoalloggi.png`}
2025-08-29 16:18:32 +02:00
property="og:logo"
2025-08-28 18:27:07 +02:00
/>
2025-08-29 16:18:32 +02:00
<meta content="Infoalloggi.it" property="og:site_name" />
<link href={meta?.ogUrl || ""} rel="canonical" />
<link
href={`${env.NEXT_PUBLIC_BASE_URL}/annuncio/${cod}`}
hrefLang="it"
rel="alternate"
/>
<link
href={`${env.NEXT_PUBLIC_BASE_URL}/en/annuncio/${cod}`}
hrefLang="en"
rel="alternate"
/>
2025-08-28 18:27:07 +02:00
</Head>
<AnnuncioView cod={cod} flag={flag} />
</main>
);
};
const AnnuncioView = ({ cod, flag }: Omit<AnnuncioProps, "meta">) => {
2025-08-28 18:27:07 +02:00
const session = useSession();
const { locale } = useTranslation();
useStaleImageReload();
2025-08-28 18:27:07 +02:00
const { data, isLoading } = api.annunci.getAnnuncio.useQuery(
{
cod: cod,
},
{ refetchOnMount: false },
);
const isDesktop = useMediaQuery("(min-width: 768px)");
if (isLoading || session.status === "pending") return <LoadingPage />;
if (
!data ||
session.status === "error" ||
(data && data.stato === "Sospeso")
) {
return <FailedAnnuncioLoading />;
}
const titolo = (locale === "it" ? data.titolo_it : data.titolo_en) || "";
const description = (locale === "it" ? data.desc_it : data.desc_en) || "";
const filteredCaratt = data.caratteristiche
? filteredCaratteristiche(data.caratteristiche)
: null;
return (
<AnnuncioContext.Provider value={data}>
<TouchProvider>
<div className="mx-auto w-full max-w-400 px-2 sm:px-8">
<CarouselAnnuncio
immagini={data.images}
updated_at={data.media_updated_at}
videos={data.videos}
/>
2025-08-28 18:27:07 +02:00
<div className="flex flex-col sm:py-8 md:flex-row-reverse md:gap-2">
<div className="mb-6 flex flex-col md:mb-0 md:w-1/3">
<div className="flex w-full flex-col flex-nowrap items-center justify-center gap-6">
<CardInfos data={data} />
{session.user?.isAdmin ? (
<>
<Link
className="w-full"
href={`/area-riservata/admin/edit-annuncio/${data.id}`}
>
<Button className="w-full" type="button">
<Edit /> Modifica annuncio
</Button>
</Link>
<Link
className="w-full"
href={`/area-riservata/admin/scheda-annuncio-stampa/${data.id}`}
target="_blank"
>
<Button
className="w-full"
type="button"
variant="outline"
>
<Printer /> Scheda immobile
</Button>
</Link>
</>
2025-08-28 18:27:07 +02:00
) : (
<AnnuncioInteractions
disabled={flag === "true"}
2025-08-29 16:18:32 +02:00
session={session}
2025-08-28 18:27:07 +02:00
/>
)}
<ComeFunziona />
2025-08-28 18:27:07 +02:00
{data.tipo &&
(data.tipo === "Transitorio" || data.tipo === "Stabile") && (
<>
<Pricing
2025-08-29 16:18:32 +02:00
isDesktop={isDesktop}
2025-08-28 18:27:07 +02:00
tipologia={
data.tipo === "Transitorio"
? TipologiaPosizioneEnum.Transitorio
: TipologiaPosizioneEnum.Stabile
}
/>
</>
)}
</div>
</div>
<div className="mx-2 md:w-2/3">
<TestiAnnuncio
description={description}
filteredCaratt={filteredCaratt}
2025-08-29 16:18:32 +02:00
titolo={titolo}
2025-08-28 18:27:07 +02:00
/>
</div>
</div>
<AnnuncioFooter
comune={data.comune}
consegna={data.consegna}
external_videos={data.external_videos || []}
2025-08-29 16:18:32 +02:00
tipo={data.tipo}
updated_at={data.media_updated_at}
videos={data.videos}
2025-08-28 18:27:07 +02:00
/>
</div>
</TouchProvider>
</AnnuncioContext.Provider>
);
2025-08-04 17:45:44 +02:00
};
export async function getStaticProps(
2025-08-28 18:27:07 +02:00
context: GetStaticPropsContext<{ cod: string }>,
2025-08-04 17:45:44 +02:00
) {
2025-08-28 18:27:07 +02:00
const ssg = generateSSGHelper();
const cod = context.params?.cod;
if (typeof cod !== "string") {
return {
notFound: true,
};
}
await ssg.annunci.getAnnuncio.prefetch({ cod: cod });
const meta = await ssg.annunci.getAnnuncioMeta.fetch({ cod: cod });
const flag = await ssg.flags.GetFlagValue.fetch({
2025-08-28 18:27:07 +02:00
id: "ANNUNCIO_INTERACTIONS_DISABLED",
});
return {
props: {
cod,
flag,
meta,
2025-08-29 16:18:32 +02:00
trpcState: ssg.dehydrate(),
2025-08-28 18:27:07 +02:00
},
revalidate: 21600, // 6 hours - ensures fresh data 4x per day
2025-08-28 18:27:07 +02:00
};
2025-08-04 17:45:44 +02:00
}
export const getStaticPaths: GetStaticPaths = async () => {
2025-08-28 18:27:07 +02:00
return {
fallback: "blocking",
2025-08-29 16:18:32 +02:00
paths: [],
2025-08-28 18:27:07 +02:00
};
2025-08-04 17:45:44 +02:00
};
export default AnnuncioDettaglio;
const Pricing = ({
2025-08-28 18:27:07 +02:00
isDesktop,
tipologia,
2025-08-04 17:45:44 +02:00
}: {
2025-08-28 18:27:07 +02:00
isDesktop: boolean;
tipologia: TipologiaPosizioneEnum;
2025-08-04 17:45:44 +02:00
}) => {
2025-08-28 18:27:07 +02:00
const { t } = useTranslation();
const { data: prezziario, isLoading } =
api.prezziario.getPrezziPerTipologia.useQuery({
tipologia,
});
if (isLoading) return <LoadingPage />;
if (!prezziario) return null;
const cta =
tipologia === TipologiaPosizioneEnum.Transitorio
? t.pricing_cmp.cta_breve
: t.pricing_cmp.cta_stabile;
const opzioni =
tipologia === TipologiaPosizioneEnum.Transitorio
? [
t.pricing_cmp.breve_option1,
t.pricing_cmp.breve_option2,
t.pricing_cmp.breve_option3,
t.pricing_cmp.breve_option4,
t.pricing_cmp.breve_option5,
]
: [t.pricing_cmp.stabile_option1, t.pricing_cmp.stabile_option2];
const style =
tipologia === TipologiaPosizioneEnum.Transitorio
? "border-transitorio bg-transitorio"
: "border-stabile bg-stabile";
const pricing = prezziario.saldi.map((s) => ({
downPayment: prezziario.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100,
}));
if (isDesktop) {
return (
<PricingComponent
className={cn(
style,
"max-w-2xl **:data-[role=wrapper]:flex-wrap **:data-[role=amount]:text-2xl lg:**:data-[role=wrapper]:flex-nowrap xl:**:data-[role=amount]:text-3xl sm:[&_#contratto-link]:h-8 xl:[&_#contratto-link]:h-9",
2025-08-28 18:27:07 +02:00
)}
2025-08-29 16:18:32 +02:00
cta={cta}
opzioni={opzioni}
pricingData={pricing}
statico={true}
tipo={tipologia}
2025-08-28 18:27:07 +02:00
title={
tipologia === TipologiaPosizioneEnum.Transitorio
? t.prezzi.titolo_transitori
: t.prezzi.titolo_stabile
}
/>
);
}
return (
<Collapsible className="w-full">
<CollapsibleTrigger asChild>
<Button
className={cn(
"w-full text-white",
tipologia === TipologiaPosizioneEnum.Transitorio
? "bg-transitorio"
: "bg-stabile",
)}
>
<div className="flex items-center gap-2">
<BadgeEuro className="size-5" />
<span>{t.pricing_cmp.cta_costo}</span>
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<PricingComponent
className={cn(style, "max-w-full")}
cta={cta}
opzioni={opzioni}
pricingData={pricing}
statico={true}
tipo={tipologia}
title={
tipologia === TipologiaPosizioneEnum.Transitorio
? t.prezzi.titolo_transitori
: t.prezzi.titolo_stabile
}
/>
</CollapsibleContent>
</Collapsible>
);
2025-08-04 17:45:44 +02:00
};
const TestiAnnuncio = ({
2025-08-28 18:27:07 +02:00
titolo,
description,
filteredCaratt,
2025-08-04 17:45:44 +02:00
}: {
2025-08-28 18:27:07 +02:00
titolo: string;
description: string;
filteredCaratt:
| {
text: string;
icon: IconType;
value: string | null;
}[]
| null;
2025-08-04 17:45:44 +02:00
}) => {
2025-08-28 18:27:07 +02:00
return (
<>
2025-10-10 16:18:43 +02:00
<h1 className="font-bold text-3xl uppercase">{titolo}</h1>
2025-08-28 18:27:07 +02:00
<h1
className="py-6"
dangerouslySetInnerHTML={{
__html: replaceWithBr(description),
}}
/>
{filteredCaratt && (
<CaratteristicheAccordion filteredCaratt={filteredCaratt} />
)}
</>
);
2025-08-04 17:45:44 +02:00
};
const CaratteristicheAccordion = ({
2025-08-28 18:27:07 +02:00
filteredCaratt,
2025-08-04 17:45:44 +02:00
}: {
2025-08-28 18:27:07 +02:00
filteredCaratt: CaratteristicheFiltered;
2025-08-04 17:45:44 +02:00
}) => {
2025-08-28 18:27:07 +02:00
return (
<Accordion
className="my-5"
collapsible
defaultValue="altre-caratt"
2025-08-29 16:18:32 +02:00
type="single"
2025-08-28 18:27:07 +02:00
>
<AccordionItem value="altre-caratt">
<AccordionTrigger>Altre caratteristiche</AccordionTrigger>
<AccordionContent>
{Object.entries(filteredCaratt).map(([key, scheda_value]) => {
const { text, icon, value } = scheda_value;
if (value === null) return null;
return (
<div
className="flex flex-row items-center gap-2 text-base"
2025-08-29 16:18:32 +02:00
key={key}
2025-08-28 18:27:07 +02:00
>
2025-08-29 16:18:32 +02:00
<IconMatrix className="size-4" type={icon} />
2025-08-28 18:27:07 +02:00
{text}: {value}
</div>
);
})}
</AccordionContent>
</AccordionItem>
</Accordion>
);
2025-08-04 17:45:44 +02:00
};
const CardInfos = ({ data }: { data: Annunci }) => {
2025-08-28 18:27:07 +02:00
const { t } = useTranslation();
return (
<Card className="relative w-full bg-secondary text-secondary-foreground outline outline-secondary-foreground">
2025-08-28 18:27:07 +02:00
<CardHeader>
2025-10-10 16:18:43 +02:00
<CardTitle className="text-center font-bold text-3xl md:pl-8 lg:pl-0">
{t.annunci.codice} {data.codice}
2025-08-28 18:27:07 +02:00
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-4 py-0">
<div className="grid grid-cols-2 gap-3 font-semibold">
<div className="relative">
<div className="rounded-md bg-primary-foreground p-2 text-center text-primary">
2025-08-28 18:27:07 +02:00
{data.prezzo && (data.prezzo / 1e2).toFixed(2).replace(".", ",")}{" "}
{t.annunci.euro_mese}
2025-08-28 18:27:07 +02:00
</div>
{data.prezzo && (
<InformationBubble
icon="euro"
iconClassName="h-[19.5px] w-[19.5px] bg-orange-500 text-white rounded-full"
>
<div className="text-fxd-foreground">
<span className="font-semibold text-sm">
{t.annunci.prezzo_giorno}
2025-08-28 18:27:07 +02:00
</span>
<div className="text-sm">
{(data.prezzo / 1e2 / 30).toFixed(2).replace(".", ",")}{" "}
{t.annunci.euro_giorno}
2025-08-28 18:27:07 +02:00
</div>
</div>
</InformationBubble>
)}
</div>
<div className="relative">
<div
className={cn(
"rounded-md bg-primary-foreground p-2 text-center text-primary outline-2",
2025-08-28 18:27:07 +02:00
data.tipo === "Transitorio" && "outline-transitorio",
data.tipo === "Stabile" && "outline-stabile",
)}
>
{data.tipo}
2025-08-28 18:27:07 +02:00
</div>
{data.tipo === "Transitorio" && (
<InformationBubble
href="/guida#faq-transitori"
target="_blank"
txt="Più informazioni sugli affitti transitori"
/>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<h3 className="px-2">{t.annunci.fornitura}:</h3>
2025-08-28 18:27:07 +02:00
<div className="grid grid-cols-2 gap-3">
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
2025-08-28 18:27:07 +02:00
<BedDouble />
{(() => {
if (!data.numero_camere) return "N/A";
if (data.numero_camere > 1)
return `${data.numero_camere} ${t.card.camere2}`;
if (data.numero_camere === 1)
return `${data.numero_camere} ${t.card.camere1}`;
})()}
2025-08-28 18:27:07 +02:00
</div>
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
2025-08-28 18:27:07 +02:00
<Bath />
<span>
{data.numero_bagni || 0} {t.annunci.bagni}
</span>
2025-08-28 18:27:07 +02:00
</div>
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
2025-08-28 18:27:07 +02:00
<Armchair />
{data.caratteristiche?.Scheda_Arredi || ""}
</div>
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
2025-08-28 18:27:07 +02:00
<Car />
<span>
{data.numero_postiauto || 0} {t.annunci.posti_auto}
</span>
2025-08-28 18:27:07 +02:00
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<h3 className="px-2">{t.annunci.dati_immobile}:</h3>
2025-08-28 18:27:07 +02:00
<div className="grid grid-cols-2 gap-3">
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
2025-08-28 18:27:07 +02:00
<Ruler />
<span>
{data.mq ? Number(data.mq).toLocaleString("it-IT") : "--"} m
<sup>2</sup>
2025-08-28 18:27:07 +02:00
</span>
</div>
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
{data.piano !== null && data.piano !== "" && (
<>
<ArrowUp01 />
<span>
{(() => {
if (data.piano === "0") return t.annunci.p_terra;
if (data.piano === "0.5") return t.annunci.p_rialzato;
if (data.piano)
return `${data.piano}° ${t.annunci.piano}`;
return "--";
})()}
</span>
</>
)}
2025-08-28 18:27:07 +02:00
</div>
</div>
</div>
<div className="text-center font-bold text-base">
2025-08-28 18:27:07 +02:00
{data.stato === "Trattativa" ? (
<div className="flex items-center justify-center gap-1 rounded-md py-1.5 text-trattativa outline-2 outline-trattativa dark:bg-secondary-foreground">
<TrafficCone className="inline-block size-5" />
<span>{t.annunci.in_aggiornamento}</span>
</div>
2025-08-28 18:27:07 +02:00
) : (
<div className="flex items-center justify-center gap-1 rounded-md py-1.5 outline-2 outline-secondary-foreground">
2025-08-28 18:27:07 +02:00
<Clock className="inline-block size-5" />
{t.annunci.disponibile}:{" "}
2025-08-28 18:27:07 +02:00
<span className="text-red-500">
{handleConsegna({
2025-08-29 16:18:32 +02:00
aggiornamento: t.card.in_aggiornamento,
2025-08-28 18:27:07 +02:00
consegna: data.consegna,
consegna_da: t.card.consegna_da,
mesi: t.parametri.mesi,
2025-08-28 18:27:07 +02:00
subito: t.card.consegna_subito,
})}
</span>
</div>
)}
</div>
<div className="flex flex-row justify-around gap-3">
<MappaDialogFullscreen
lat={data.lat_secondario}
long={data.lon_secondario}
/>
<ShareComponent />
</div>
</CardContent>
</Card>
);
2025-08-04 17:45:44 +02:00
};
const ShareComponent = () => {
2025-08-28 18:27:07 +02:00
const { t } = useTranslation();
const [link, setLink] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setLink(window.location.href);
}
}, []);
const handleCopyLink = async () => {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(link);
toast(t.share.copiato, { icon: "📋" });
} catch (err) {
console.error("Failed to copy: ", err);
}
} else {
// Fallback for browsers that do not support navigator.clipboard.writeText
const textArea = document.createElement("textarea");
textArea.value = link;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
toast(t.share.copiato, { icon: "📋" });
} catch (err) {
console.error("Failed to copy: ", err);
}
document.body.removeChild(textArea);
}
};
return (
<Dialog>
<DialogTrigger asChild>
<Button>
<Share2 />
2025-08-28 18:27:07 +02:00
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t.share.condividi}</DialogTitle>
<DialogDescription>{t.share.descrizione}</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2">
<div className="grid flex-1 gap-2">
2025-08-29 16:18:32 +02:00
<Label className="sr-only" htmlFor="link">
2025-08-28 18:27:07 +02:00
Link
</Label>
2025-08-29 16:18:32 +02:00
<Input defaultValue={link} id="link" readOnly />
2025-08-28 18:27:07 +02:00
</div>
<Button
className="px-3"
onClick={async () => await handleCopyLink()}
2025-08-29 16:18:32 +02:00
size="sm"
type="submit"
2025-08-28 18:27:07 +02:00
>
<span className="sr-only">Copy</span>
<Copy />
</Button>
</div>
</DialogContent>
</Dialog>
);
2025-08-04 17:45:44 +02:00
};
const AnnuncioFooter = ({
2025-08-28 18:27:07 +02:00
tipo,
comune,
consegna,
videos,
external_videos,
updated_at,
2025-08-04 17:45:44 +02:00
}: {
2025-08-28 18:27:07 +02:00
tipo: string | null;
comune: string | null;
consegna: number | null;
videos: Pick<VideosRefs, "video" | "thumb">[];
external_videos: string[];
updated_at: Date | null;
2025-08-04 17:45:44 +02:00
}) => {
const { t } = useTranslation();
2025-08-28 18:27:07 +02:00
return (
<div className="flex flex-col gap-2">
2025-10-10 16:18:43 +02:00
<p className="px-2 font-semibold text-lg">Video:</p>
2025-08-28 18:27:07 +02:00
<div className="flex flex-col flex-wrap gap-4 sm:flex-row sm:gap-2 sm:px-2">
{videos.map((video) => {
return (
<VideoPlayer
className="h-96 max-w-96"
coverImage={getStorageUrl({
storageId: video.thumb,
params: { cacheKey: updated_at?.toISOString(), media: "image" },
})}
key={`videoplayer-${video.video}`} // Cache busting
videoSrc={getStorageUrl({
storageId: video.video,
params: { cacheKey: updated_at?.toISOString(), media: "video" },
})}
/>
);
})}
{external_videos.map((videoUrl) => {
const embedUrl = getYouTubeEmbedUrl(videoUrl);
if (!embedUrl) return null;
return (
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="h-96 max-w-96 rounded-lg"
key={`external-videoplayer-${videoUrl}`}
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
src={embedUrl}
title="YouTube video player"
/>
);
2025-08-28 18:27:07 +02:00
})}
</div>
<Link
href={{
pathname: "/annunci",
query: {
c: comune,
f: consegna,
t: tipo,
2025-08-28 18:27:07 +02:00
},
}}
target="_blank"
>
<Button className="my-10 w-full text-xl" variant="secondary">
{t.annunci.find_similar} <ExternalLink className="size-6" />
2025-08-28 18:27:07 +02:00
</Button>
</Link>
</div>
);
2025-08-04 17:45:44 +02:00
};
export const getYouTubeEmbedUrl = (url: string): string | null => {
try {
const urlObj = new URL(url);
let videoId: string | null = null;
// youtube.com/watch?v=VIDEO_ID
if (
urlObj.hostname.includes("youtube.com") &&
urlObj.pathname === "/watch"
) {
videoId = urlObj.searchParams.get("v");
}
// youtu.be/VIDEO_ID
if (urlObj.hostname === "youtu.be") {
videoId = urlObj.pathname.slice(1);
}
// youtube.com/embed/VIDEO_ID (already embed format)
if (
urlObj.hostname.includes("youtube.com") &&
urlObj.pathname.startsWith("/embed/")
) {
videoId = urlObj.pathname.split("/embed/")[1]?.split("?")[0] || null;
}
if (!videoId) return null;
// Use youtube-nocookie.com domain (privacy-enhanced mode)
// Add parameters to reduce ads and suggestions
const params = new URLSearchParams({
rel: "0", // Don't show related videos from other channels
modestbranding: "1", // Minimal YouTube branding
controls: "1", // Show player controls
showinfo: "0", // Don't show video info before playing
fs: "1", // Allow fullscreen
iv_load_policy: "3", // Hide video annotations
disablekb: "0", // Enable keyboard controls
autoplay: "0", // Don't autoplay
// cc_load_policy: "1", // Show closed captions (optional)
});
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`;
} catch {
return null;
}
};