766 lines
22 KiB
TypeScript
766 lines
22 KiB
TypeScript
import {
|
|
Armchair,
|
|
ArrowUp01,
|
|
BadgeEuro,
|
|
Bath,
|
|
BedDouble,
|
|
Building,
|
|
Car,
|
|
Clock,
|
|
Copy,
|
|
ExternalLink,
|
|
Ruler,
|
|
Share2,
|
|
TrafficCone,
|
|
Warehouse,
|
|
} from "lucide-react";
|
|
import type { GetStaticPaths, GetStaticPropsContext, NextPage } from "next";
|
|
import Head from "next/head";
|
|
import Link from "next/link";
|
|
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 { ComeFunziona } from "~/components/expand_guida";
|
|
import FailedAnnuncioLoading from "~/components/failed-loading";
|
|
import { IconMatrix } from "~/components/IconComponents";
|
|
import InformationBubble from "~/components/InformationBubble";
|
|
import { LoadingPage } from "~/components/loading";
|
|
import { MappaDialogFullscreen } from "~/components/MapDialog";
|
|
import { PricingComponent, type PricingData } from "~/components/prezzi";
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
} from "~/components/ui/accordion";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "~/components/ui/collapsible";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "~/components/ui/dialog";
|
|
import Input from "~/components/ui/input";
|
|
import { Label } from "~/components/ui/label";
|
|
import { VideoPlayer } from "~/components/videoPlayer";
|
|
import { env } from "~/env";
|
|
import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils";
|
|
import { useMediaQuery } from "~/hooks/use-media-query";
|
|
import { handleConsegna } from "~/lib/annuncio_details";
|
|
import { replaceWithBr } from "~/lib/newlineToBr";
|
|
import { getStorageUrl } from "~/lib/storage_utils";
|
|
import { cn, redirectTo500 } from "~/lib/utils";
|
|
import { AnnuncioContext, useAnnuncio } from "~/providers/AnnuncioProvider";
|
|
import { useTranslation } from "~/providers/I18nProvider";
|
|
import { buildRicercaUrl } from "~/providers/RicercaProvider";
|
|
import { tipologiaPosizioneEnum } from "~/schemas/public/TipologiaPosizioneEnum";
|
|
import type { AnnuncioData } from "~/server/controllers/annunci.controller";
|
|
import { generateSSGHelper } from "~/server/utils/ssgHelper";
|
|
import { api } from "~/utils/api";
|
|
|
|
type AnnuncioProps = {
|
|
data: AnnuncioData;
|
|
flag: string;
|
|
};
|
|
|
|
/**
|
|
* Pagina di dettaglio annuncio: /annuncio/[cod]
|
|
* Questa pagina mostra i dettagli di un annuncio specifico, identificato dal codice (cod) passato come parametro. Utilizza getStaticProps per prefetchare i dati dell'annuncio e le relative informazioni meta per SEO e condivisione sui social media. Se l'annuncio è in stato "Sospeso" o se si verifica un errore durante il caricamento, viene mostrata una pagina di errore.
|
|
*/
|
|
const AnnuncioDettaglio: NextPage<AnnuncioProps> = ({
|
|
data,
|
|
flag,
|
|
}: AnnuncioProps) => {
|
|
return (
|
|
<main>
|
|
<Head>
|
|
<title>{`${data.codice} | ${data.titolo_it}`}</title>
|
|
<meta content={data.desc_it || ""} property="description" />
|
|
<meta content={data.ogUrl} property="og:url" />
|
|
<meta content="website" property="og:type" />
|
|
<meta
|
|
content={`${data.codice} | ${data.titolo_it}`}
|
|
property="og:title"
|
|
/>
|
|
<meta content={data.desc_it || ""} property="og:description" />
|
|
<meta content={data.ogImage} property="og:image" />
|
|
<meta content="1200" property="og:image:width" />
|
|
<meta content="630" property="og:image:height" />
|
|
<meta
|
|
content={`${env.NEXT_PUBLIC_BASE_URL}/Infoalloggi.png`}
|
|
property="og:logo"
|
|
/>
|
|
<meta content="Infoalloggi.it" property="og:site_name" />
|
|
<link href={data.ogUrl || ""} rel="canonical" />
|
|
<link
|
|
href={`${env.NEXT_PUBLIC_BASE_URL}/annuncio/${data.codice}`}
|
|
hrefLang="it"
|
|
rel="alternate"
|
|
/>
|
|
<link
|
|
href={`${env.NEXT_PUBLIC_BASE_URL}/en/annuncio/${data.codice}`}
|
|
hrefLang="en"
|
|
rel="alternate"
|
|
/>
|
|
</Head>
|
|
<AnnuncioView data={data} flag={flag} />
|
|
</main>
|
|
);
|
|
};
|
|
|
|
const AnnuncioView = ({ data, flag }: AnnuncioProps) => {
|
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
|
|
if (!data || (data && data.stato === "Sospeso")) {
|
|
return <FailedAnnuncioLoading />;
|
|
}
|
|
|
|
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 ? new Date(data.media_updated_at) : null
|
|
}
|
|
videos={data.videos}
|
|
/>
|
|
|
|
<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 />
|
|
<AnnuncioInteractions disabled={flag === "true"} />
|
|
<ComeFunziona />
|
|
{data.tipo &&
|
|
(data.tipo === "Transitorio" || data.tipo === "Stabile") && (
|
|
<>
|
|
<Pricing isDesktop={isDesktop} />
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="mx-2 md:w-2/3">
|
|
<TestiAnnuncio />
|
|
</div>
|
|
</div>
|
|
<AnnuncioFooter />
|
|
</div>
|
|
</TouchProvider>
|
|
</AnnuncioContext.Provider>
|
|
);
|
|
};
|
|
|
|
export async function getStaticProps(
|
|
context: GetStaticPropsContext<{ cod: string }>,
|
|
) {
|
|
const ssg = generateSSGHelper();
|
|
const cod = context.params?.cod;
|
|
if (typeof cod !== "string") {
|
|
return {
|
|
redirect: {
|
|
destination: "/annuncio-non-trovato",
|
|
permanent: false,
|
|
},
|
|
};
|
|
}
|
|
const result = await ssg.annunci.getAnnuncioData.fetch({ cod: cod });
|
|
|
|
if (!result.ok) {
|
|
if (result.error._tag === "InternalError") {
|
|
return redirectTo500;
|
|
}
|
|
|
|
return {
|
|
redirect: {
|
|
destination: "/annuncio-non-trovato",
|
|
permanent: false,
|
|
},
|
|
};
|
|
}
|
|
if (result.value.stato === "Sospeso") {
|
|
return {
|
|
redirect: {
|
|
destination: "/annuncio-non-trovato",
|
|
permanent: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
const flag = await ssg.flags.GetFlagValue.fetch(
|
|
"ANNUNCIO_INTERACTIONS_DISABLED",
|
|
);
|
|
return {
|
|
props: {
|
|
data: result.value,
|
|
//cod,
|
|
flag,
|
|
//meta,
|
|
trpcState: ssg.dehydrate(),
|
|
},
|
|
revalidate: 21600, // 6 hours - ensures fresh data 4x per day
|
|
};
|
|
}
|
|
|
|
export const getStaticPaths: GetStaticPaths = async () => {
|
|
return {
|
|
fallback: "blocking",
|
|
paths: [],
|
|
};
|
|
};
|
|
|
|
export default AnnuncioDettaglio;
|
|
|
|
const Pricing = ({ isDesktop }: { isDesktop: boolean }) => {
|
|
const { t } = useTranslation();
|
|
const { prezzo, tipo } = useAnnuncio();
|
|
const tipologia =
|
|
tipo === "Transitorio"
|
|
? tipologiaPosizioneEnum.enum.Transitorio
|
|
: tipologiaPosizioneEnum.enum.Stabile;
|
|
const { data: prezziario, isLoading } =
|
|
api.prezziario.getPrezziPerTipologia.useQuery({
|
|
tipologia,
|
|
});
|
|
|
|
if (isLoading) return <LoadingPage />;
|
|
if (!prezziario) return null;
|
|
|
|
const opzioniT = [
|
|
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,
|
|
];
|
|
const opzioniS = [
|
|
t.pricing_cmp.stabile_option1,
|
|
t.pricing_cmp.stabile_option2,
|
|
t.pricing_cmp.stabile_option3,
|
|
];
|
|
|
|
const cta =
|
|
tipologia === tipologiaPosizioneEnum.enum.Transitorio
|
|
? t.pricing_cmp.cta_breve
|
|
: t.pricing_cmp.cta_stabile;
|
|
|
|
const pricing = prezziario.saldi.map((s) => ({
|
|
downPayment: prezziario.acconto.prezzo_cent / 100,
|
|
secondPayment: s.prezzo_cent / 100,
|
|
}));
|
|
|
|
let opzioni: string[] = [];
|
|
let pricingData: PricingData = [];
|
|
|
|
if (tipologia === tipologiaPosizioneEnum.enum.Stabile) {
|
|
//se stabile, mostra il prezzo basato sul canone in default
|
|
const idx = prezzo > 70000 ? 2 : prezzo > 50000 ? 1 : 0;
|
|
const opzione = opzioniS[idx];
|
|
const pricingS = pricing[idx];
|
|
if (opzione && pricingS) {
|
|
opzioni = [opzione];
|
|
pricingData = [pricingS];
|
|
} else {
|
|
opzioni = opzioniS;
|
|
pricingData = pricing;
|
|
}
|
|
} else {
|
|
opzioni = opzioniT;
|
|
pricingData = pricing;
|
|
}
|
|
|
|
const style =
|
|
tipologia === tipologiaPosizioneEnum.enum.Transitorio
|
|
? "border-transitorio bg-transitorio"
|
|
: "border-stabile bg-stabile";
|
|
|
|
const title =
|
|
tipologia === tipologiaPosizioneEnum.enum.Transitorio
|
|
? t.prezzi.titolo_transitori
|
|
: t.prezzi.titolo_stabile;
|
|
|
|
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",
|
|
)}
|
|
cta={cta}
|
|
opzioni={opzioni}
|
|
pricingData={pricingData}
|
|
statico={true}
|
|
tipo={tipologia}
|
|
title={title}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<Collapsible className="w-full">
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
className={cn(
|
|
"w-full text-white",
|
|
tipologia === tipologiaPosizioneEnum.enum.Transitorio
|
|
? "bg-transitorio"
|
|
: "bg-stabile",
|
|
)}
|
|
data-umami-event={`pricing_mobile_cta_click`}
|
|
>
|
|
<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={pricingData}
|
|
statico={true}
|
|
tipo={tipologia}
|
|
title={title}
|
|
/>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
};
|
|
|
|
const TestiAnnuncio = () => {
|
|
const { locale } = useTranslation();
|
|
const { titolo_it, titolo_en, desc_it, desc_en } = useAnnuncio();
|
|
|
|
return (
|
|
<>
|
|
<h1 className="font-bold text-3xl uppercase">
|
|
{locale === "it" ? titolo_it : titolo_en}
|
|
</h1>
|
|
<h1
|
|
className="py-6"
|
|
dangerouslySetInnerHTML={{
|
|
__html: replaceWithBr((locale === "it" ? desc_it : desc_en) || ""),
|
|
}}
|
|
/>
|
|
|
|
<CaratteristicheAccordion />
|
|
</>
|
|
);
|
|
};
|
|
|
|
const CaratteristicheAccordion = () => {
|
|
const { caratteristiche } = useAnnuncio();
|
|
if (!caratteristiche) return null;
|
|
return (
|
|
<Accordion
|
|
className="my-5"
|
|
collapsible
|
|
defaultValue="altre-caratt"
|
|
type="single"
|
|
>
|
|
<AccordionItem
|
|
className="border-b dark:border-muted-foreground/50"
|
|
value="altre-caratt"
|
|
>
|
|
<AccordionTrigger>Altre caratteristiche</AccordionTrigger>
|
|
<AccordionContent>
|
|
{Object.entries(filteredCaratteristiche(caratteristiche)).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"
|
|
key={key}
|
|
>
|
|
<IconMatrix className="size-4" type={icon} />
|
|
{text}: {value}
|
|
</div>
|
|
);
|
|
},
|
|
)}
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
);
|
|
};
|
|
|
|
const CardInfos = () => {
|
|
const { t } = useTranslation();
|
|
const {
|
|
codice,
|
|
prezzo,
|
|
tipo,
|
|
numero_camere,
|
|
numero_bagni,
|
|
caratteristiche,
|
|
numero_postiauto,
|
|
numero_box,
|
|
anno,
|
|
mq,
|
|
piano,
|
|
stato,
|
|
consegna,
|
|
lat_secondario,
|
|
lon_secondario,
|
|
} = useAnnuncio();
|
|
return (
|
|
<Card className="relative w-full bg-secondary text-secondary-foreground outline outline-secondary-foreground">
|
|
<CardHeader>
|
|
<CardTitle className="text-center font-bold text-3xl md:pl-8 lg:pl-0">
|
|
{t.annunci.codice} {codice}
|
|
</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="flex h-10 items-center justify-center rounded-md bg-primary-foreground text-center text-primary">
|
|
{prezzo && (prezzo / 1e2).toFixed(2).replace(".", ",")}{" "}
|
|
{t.annunci.euro_mese}
|
|
</div>
|
|
{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}
|
|
</span>
|
|
<div className="text-sm">
|
|
{(prezzo / 1e2 / 30).toFixed(2).replace(".", ",")}{" "}
|
|
{t.annunci.euro_giorno}
|
|
</div>
|
|
</div>
|
|
</InformationBubble>
|
|
)}
|
|
</div>
|
|
<div className="relative">
|
|
<div
|
|
className={cn(
|
|
"h-10 rounded-md bg-primary-foreground p-2 text-center text-primary outline-2",
|
|
tipo === "Transitorio" && "outline-transitorio",
|
|
tipo === "Stabile" && "outline-stabile",
|
|
)}
|
|
>
|
|
{tipo}
|
|
</div>
|
|
{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>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
|
|
<BedDouble />
|
|
<span className="line-clamp-1 break-all">
|
|
{(() => {
|
|
if (!numero_camere) return "N/A";
|
|
if (numero_camere > 1)
|
|
return `${numero_camere} ${t.card.camere2}`;
|
|
if (numero_camere === 1)
|
|
return `${numero_camere} ${t.card.camere1}`;
|
|
})()}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
|
|
<Bath />
|
|
<span className="line-clamp-1 break-all">
|
|
{numero_bagni || 0} {t.annunci.bagni}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
|
|
<Armchair />
|
|
<span className="line-clamp-1 break-all">
|
|
{caratteristiche?.Scheda_Arredi || ""}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
|
|
<Building />
|
|
<span className="line-clamp-1 break-all">{anno || ""}</span>
|
|
</div>
|
|
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
|
|
<Car />
|
|
<span className="line-clamp-1 break-all">
|
|
{numero_postiauto || 0} {t.annunci.posti_auto}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
|
|
<Warehouse />
|
|
<span className="line-clamp-1 break-all">
|
|
{numero_box || 0} Garage
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<h3 className="px-2">{t.annunci.dati_immobile}:</h3>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
|
|
<Ruler />
|
|
<span className="line-clamp-1 break-all">
|
|
{mq ? Number(mq).toLocaleString("it-IT") : "--"} m<sup>2</sup>
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
|
|
{piano !== null && piano !== "" && (
|
|
<>
|
|
<ArrowUp01 />
|
|
<span className="line-clamp-1 break-all">
|
|
{(() => {
|
|
if (piano === "0") return t.annunci.p_terra;
|
|
if (piano === "0.5") return t.annunci.p_rialzato;
|
|
if (piano) return `${piano}° ${t.annunci.piano}`;
|
|
return "--";
|
|
})()}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-center font-bold text-base">
|
|
{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>
|
|
) : (
|
|
<div className="flex items-center justify-center gap-1 rounded-md py-1.5 outline-2 outline-secondary-foreground">
|
|
<Clock className="inline-block size-5" />
|
|
{t.annunci.disponibile}:{" "}
|
|
<span className="text-red-500">
|
|
{handleConsegna({
|
|
aggiornamento: t.card.in_aggiornamento,
|
|
consegna: consegna,
|
|
consegna_da: t.card.consegna_da,
|
|
mesi: t.parametri.mesi,
|
|
subito: t.card.consegna_subito,
|
|
})}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-row justify-around gap-3">
|
|
<MappaDialogFullscreen lat={lat_secondario} long={lon_secondario} />
|
|
|
|
<ShareComponent />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
const ShareComponent = () => {
|
|
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 data-umami-event="share_button_click">
|
|
<Share2 />
|
|
</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">
|
|
<Label className="sr-only" htmlFor="link">
|
|
Link
|
|
</Label>
|
|
<Input defaultValue={link} id="link" readOnly />
|
|
</div>
|
|
<Button
|
|
className="px-3"
|
|
onClick={async () => await handleCopyLink()}
|
|
size="sm"
|
|
type="submit"
|
|
>
|
|
<span className="sr-only">Copy</span>
|
|
<Copy />
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
const AnnuncioFooter = () => {
|
|
const { tipo, comune, consegna, videos, external_videos, media_updated_at } =
|
|
useAnnuncio();
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<p
|
|
className={cn(
|
|
"px-2 font-semibold text-lg",
|
|
videos.length === 0 && external_videos?.length === 0 && "hidden",
|
|
)}
|
|
>
|
|
Video:
|
|
</p>
|
|
<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({
|
|
id: video.thumb,
|
|
params: {
|
|
cacheKey: media_updated_at || undefined,
|
|
media: "image",
|
|
},
|
|
})}
|
|
key={`videoplayer-${video.video}`} // Cache busting
|
|
videoSrc={getStorageUrl({
|
|
id: video.video,
|
|
params: {
|
|
cacheKey: media_updated_at || undefined,
|
|
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"
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<Link
|
|
href={buildRicercaUrl({
|
|
comune: comune || undefined,
|
|
consegna: consegna ? [consegna] : undefined,
|
|
tipo:
|
|
tipo in ["Transitorio", "Stabile"]
|
|
? (tipo as "Transitorio" | "Stabile")
|
|
: undefined,
|
|
})}
|
|
target="_blank"
|
|
>
|
|
<Button
|
|
className="my-10 w-full text-xl"
|
|
data-umami-event="similar_listings_click"
|
|
variant="secondary"
|
|
>
|
|
{t.annunci.find_similar} <ExternalLink className="size-6" />
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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;
|
|
}
|
|
};
|