593 lines
14 KiB
TypeScript
593 lines
14 KiB
TypeScript
import { keepPreviousData } from "@tanstack/react-query";
|
|
import {
|
|
ChevronDown,
|
|
Filter,
|
|
FilterX,
|
|
MapIcon,
|
|
RotateCcwIcon,
|
|
Table,
|
|
} from "lucide-react";
|
|
import type { GetServerSideProps } from "next";
|
|
import dynamic from "next/dynamic";
|
|
import Head from "next/head";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { AccordionComp } from "~/components/accordionComp";
|
|
import { CTA_TipologiaModal } from "~/components/annunci_tutorial";
|
|
import { LazyCardAnnuncio } from "~/components/annuncio_card";
|
|
import { CodiceBox } from "~/components/codiceRicerca";
|
|
import { Layout } from "~/components/Layout";
|
|
import { LoadingPage } from "~/components/loading";
|
|
import ScrollToTop from "~/components/scroll_top";
|
|
import { Status500 } from "~/components/status-page";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Button } from "~/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuTrigger,
|
|
} from "~/components/ui/dropdown-menu";
|
|
import { Label } from "~/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "~/components/ui/select";
|
|
import { Slider } from "~/components/ui/slider";
|
|
import { useMediaQuery } from "~/hooks/use-media-query";
|
|
import { cn, debounce } from "~/lib/utils";
|
|
import { useTranslation } from "~/providers/I18nProvider";
|
|
import {
|
|
type AnnunciOptions,
|
|
type FilterOption,
|
|
RicercaProvider,
|
|
type SortTypes,
|
|
useRicerca,
|
|
} from "~/providers/RicercaProvider";
|
|
import { generateSSGHelper } from "~/server/utils/ssgHelper";
|
|
import { api } from "~/utils/api";
|
|
|
|
const MapSection = dynamic(
|
|
() =>
|
|
import("~/components/annunci_map").then((mod) => ({
|
|
default: mod.MapSection,
|
|
})),
|
|
{
|
|
ssr: false,
|
|
loading: () => <LoadingPage />,
|
|
},
|
|
);
|
|
|
|
type AnnunciPageProps = {
|
|
options: AnnunciOptions;
|
|
};
|
|
|
|
const Annunci = ({ options }: AnnunciPageProps) => {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<RicercaProvider options={options}>
|
|
<Head>
|
|
<title>{t.heads.annunci_titolo}</title>
|
|
<meta content={t.heads.annunci_description} name="description" />
|
|
</Head>
|
|
|
|
<main className="relative mx-auto w-full max-w-500 space-y-5 bg-background px-2 py-5 md:px-8">
|
|
<Badge className="bg-muted py-1 text-foreground text-sm outline outline-muted-foreground">
|
|
{t.annunci.titolo}
|
|
</Badge>
|
|
<ScrollToTop />
|
|
<Ricerca />
|
|
<AccordionComp
|
|
className="max-w-6xl px-4 py-4 md:py-8"
|
|
defaultOpen={0}
|
|
texts={t.annunci.accordion_minifaq}
|
|
/>
|
|
</main>
|
|
</RicercaProvider>
|
|
);
|
|
};
|
|
|
|
export default Annunci;
|
|
|
|
Annunci.getLayout = (page: React.ReactNode) => {
|
|
return <Layout>{page}</Layout>;
|
|
};
|
|
|
|
export const getServerSideProps = (async () => {
|
|
const helper = generateSSGHelper();
|
|
const options = await helper.annunci.getAnnunciOptions.fetch();
|
|
return {
|
|
props: {
|
|
options,
|
|
},
|
|
};
|
|
}) satisfies GetServerSideProps<AnnunciPageProps>;
|
|
|
|
const Ricerca = () => {
|
|
const { map, isLoading } = useRicerca();
|
|
|
|
return (
|
|
<div className="mx-auto w-full space-y-5">
|
|
<RicercaHeader />
|
|
<div className="relative flex w-full gap-4">
|
|
<RicercaSideFilters />
|
|
|
|
<div className="flex-1">
|
|
{isLoading ? <LoadingPage /> : map ? <MapSection /> : <AnnunciList />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const sort_options: FilterOption<SortTypes>[] = [
|
|
{ value: "Recenti", disabled: false },
|
|
{ value: "Prezzo", disabled: false },
|
|
{ value: "Consegna", disabled: false },
|
|
{ value: "Mq", disabled: false },
|
|
];
|
|
|
|
const Filters = () => {
|
|
const { t } = useTranslation();
|
|
const {
|
|
tipo,
|
|
comune,
|
|
sort,
|
|
comuniOptions,
|
|
tipologieOptions,
|
|
setAllParams,
|
|
reset,
|
|
nFiltri,
|
|
} = useRicerca();
|
|
|
|
return (
|
|
<>
|
|
<SelectFilter
|
|
defaultValue={""}
|
|
handleReset={async () => {
|
|
await setAllParams({
|
|
tipo: "",
|
|
});
|
|
}}
|
|
id="tipologia-filter"
|
|
label={t.annunci.fs_tipologia.titolo}
|
|
onValueChange={async (value: string) => {
|
|
await setAllParams({
|
|
tipo: value,
|
|
});
|
|
}}
|
|
options={tipologieOptions}
|
|
placeholder={t.seleziona_placeholder}
|
|
value={tipo}
|
|
/>
|
|
<SelectFilter
|
|
defaultValue={undefined}
|
|
handleReset={async () => {
|
|
await setAllParams({
|
|
comune: null,
|
|
});
|
|
}}
|
|
id="comune-filter"
|
|
key={comune}
|
|
label={t.annunci.comune}
|
|
onValueChange={async (value: string) => {
|
|
await setAllParams({
|
|
comune: value,
|
|
});
|
|
}}
|
|
options={comuniOptions}
|
|
placeholder={t.seleziona_placeholder}
|
|
value={comune || undefined}
|
|
/>
|
|
<ConsegnaFilter />
|
|
<BudgetFilters />
|
|
<SelectFilter
|
|
defaultValue={sort}
|
|
handleReset={async () => {
|
|
await setAllParams({
|
|
sort: "Recenti",
|
|
});
|
|
}}
|
|
id="sort-filter"
|
|
label={t.ordina_per}
|
|
onValueChange={async (value: SortTypes) => {
|
|
await setAllParams({
|
|
sort: value,
|
|
});
|
|
}}
|
|
options={sort_options}
|
|
placeholder={t.seleziona_placeholder}
|
|
value={sort}
|
|
/>
|
|
<Button
|
|
className="flex flex-row gap-2"
|
|
disabled={nFiltri === 0}
|
|
onClick={reset}
|
|
variant="destructive"
|
|
>
|
|
<FilterX /> {t.annunci.filtri_reset}
|
|
</Button>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const RicercaSideFilters = () => {
|
|
const isBigScreen = useMediaQuery("(min-width: 96rem)");
|
|
|
|
if (!isBigScreen) return null;
|
|
return (
|
|
<div className="flex h-fit w-full max-w-72 shrink-0 grow-0 flex-col gap-3 overflow-clip rounded-md bg-secondary p-2 outline-neutral-300">
|
|
<Filters />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DrowdownFilters = () => {
|
|
const { t } = useTranslation();
|
|
const { nFiltri } = useRicerca();
|
|
const isBigScreen = useMediaQuery("(min-width: 96rem)");
|
|
|
|
if (isBigScreen) return null;
|
|
return (
|
|
<div className="flex w-full sm:w-auto 2xl:hidden">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
className="group flex w-full flex-row gap-2 sm:w-auto"
|
|
variant="default"
|
|
>
|
|
<Badge className="w-fit rounded-md bg-secondary px-1.5 py-0.5 text-secondary-foreground dark:group-hover:bg-transparent">
|
|
{nFiltri}
|
|
</Badge>
|
|
<Filter />{" "}
|
|
{nFiltri === 1 ? t.annunci.filtro_attivo : t.annunci.filtri_attivi}
|
|
<ChevronDown />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className="flex w-full min-w-80 max-w-3xl flex-col gap-2 p-4"
|
|
>
|
|
<Filters />
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const RicercaHeader = () => {
|
|
const { map, setAllParams } = useRicerca();
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-500 space-y-5">
|
|
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row">
|
|
<div className="flex w-full items-center gap-2 sm:w-auto">
|
|
<DrowdownFilters />
|
|
<Button
|
|
className="flex w-fit flex-row items-center gap-2 md:w-24"
|
|
onClick={async () => {
|
|
await setAllParams({
|
|
map: !map,
|
|
});
|
|
}}
|
|
variant="default"
|
|
>
|
|
{!map ? (
|
|
<>
|
|
<MapIcon /> <span className="hidden sm:block">Mappa</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Table />
|
|
<span className="hidden sm:block">Lista</span>
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<div className="flex w-full items-center justify-between gap-2 sm:justify-between">
|
|
<div className="relative z-35 flex w-full gap-1 sm:w-auto">
|
|
<CodiceBox
|
|
className="h-9 w-full"
|
|
inputId="annunci-search"
|
|
optionBoxClassName="top-11 "
|
|
/>
|
|
</div>
|
|
|
|
<CTA_TipologiaModal />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const AnnunciList = () => {
|
|
const {
|
|
tipo,
|
|
comune,
|
|
consegna,
|
|
sort,
|
|
map,
|
|
minBudget,
|
|
maxBudget,
|
|
isLoading: isUpdatingRicerca,
|
|
} = useRicerca();
|
|
|
|
const { data, status, isLoading } = api.annunci.annunciRicerca.useQuery(
|
|
{
|
|
comune: comune || undefined,
|
|
consegna: consegna || undefined,
|
|
sort: sort || undefined,
|
|
tipologia: tipo || undefined,
|
|
minBudget: minBudget || undefined,
|
|
maxBudget: maxBudget || undefined,
|
|
},
|
|
{
|
|
enabled: !map && !isUpdatingRicerca,
|
|
placeholderData: keepPreviousData,
|
|
staleTime: 5000,
|
|
},
|
|
);
|
|
|
|
if (isLoading) return <LoadingPage />;
|
|
if (status === "error") return <Status500 />;
|
|
|
|
return (
|
|
<div className="w-full space-y-4">
|
|
{status === "pending" ? (
|
|
<LoadingPage />
|
|
) : (
|
|
<div className="relative z-0 mx-auto grid w-full grid-flow-row grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
|
{data.map((annuncio, idx) => (
|
|
<LazyCardAnnuncio
|
|
key={annuncio.codice}
|
|
{...annuncio}
|
|
eager={idx <= 9}
|
|
videos={[]}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface SelectFilterProps<T> {
|
|
label: string;
|
|
id: string;
|
|
options: FilterOption<string>[];
|
|
defaultValue?: T;
|
|
value: T | undefined;
|
|
onValueChange: (value: T) => void;
|
|
handleReset: () => void;
|
|
placeholder: string;
|
|
}
|
|
|
|
export const SelectFilter = <T extends string>({
|
|
label,
|
|
id,
|
|
options,
|
|
defaultValue,
|
|
value,
|
|
onValueChange,
|
|
placeholder,
|
|
handleReset,
|
|
}: SelectFilterProps<T>) => {
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex h-4 flex-row gap-4 px-1">
|
|
<Label
|
|
className={cn(
|
|
"font-semibold text-muted-foreground",
|
|
value !== defaultValue && "text-primary",
|
|
)}
|
|
htmlFor={id}
|
|
>
|
|
{label}
|
|
</Label>
|
|
{value !== defaultValue && (
|
|
<button
|
|
className="cursor-pointer"
|
|
onClick={handleReset}
|
|
type="button"
|
|
>
|
|
<RotateCcwIcon className="size-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<Select
|
|
defaultValue={defaultValue}
|
|
onValueChange={onValueChange}
|
|
value={value}
|
|
>
|
|
<SelectTrigger
|
|
className="w-full bg-primary-foreground text-primary"
|
|
id={id}
|
|
>
|
|
<SelectValue placeholder={placeholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((option) => (
|
|
<SelectItem
|
|
className="text-left"
|
|
disabled={option.disabled}
|
|
key={option.value}
|
|
value={option.value}
|
|
>
|
|
{option.value}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ConsegnaFilter = () => {
|
|
const { t } = useTranslation();
|
|
const { consegna: consegna2, setAllParams, consegnaOptions } = useRicerca();
|
|
|
|
const mappedConsegnaOptions = useMemo(() => {
|
|
if (consegnaOptions.length === 0) return [];
|
|
return consegnaOptions
|
|
.map((c) =>
|
|
t.parametri.mesi[c.value]
|
|
? {
|
|
value: c.value,
|
|
label: t.parametri.mesi[c.value],
|
|
disabled: c.disabled,
|
|
}
|
|
: null,
|
|
)
|
|
.filter((v) => v !== null);
|
|
}, [consegnaOptions, t.parametri.mesi]);
|
|
|
|
const onValueChange = async (value: number) => {
|
|
if (consegna2?.includes(value)) {
|
|
const newConsegna2 = consegna2.filter((c) => c !== value);
|
|
await setAllParams({
|
|
consegna: newConsegna2.length > 0 ? newConsegna2 : null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const newConsegna2 = consegna2 ? [...consegna2, value] : [value];
|
|
await setAllParams({
|
|
consegna: newConsegna2,
|
|
});
|
|
return;
|
|
};
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex h-4 flex-row gap-4 px-1">
|
|
<Label
|
|
className={cn(
|
|
"font-semibold text-muted-foreground",
|
|
consegna2 !== null && consegna2.length > 0 && "text-primary",
|
|
)}
|
|
>
|
|
{t.annunci.consegna}
|
|
</Label>
|
|
{consegna2 !== null && consegna2.length > 0 && (
|
|
<button
|
|
className="cursor-pointer"
|
|
onClick={async () => {
|
|
await setAllParams({
|
|
consegna: null,
|
|
});
|
|
}}
|
|
type="button"
|
|
>
|
|
<RotateCcwIcon className="size-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-1 rounded-md bg-background p-1">
|
|
{mappedConsegnaOptions.map(({ value, label, disabled }) => (
|
|
<Button
|
|
className={cn(
|
|
consegna2?.includes(value) && "border border-primary",
|
|
)}
|
|
disabled={disabled && !consegna2?.includes(value)}
|
|
key={value}
|
|
onClick={async () => {
|
|
await onValueChange(value);
|
|
}}
|
|
size="sm"
|
|
variant={consegna2?.includes(value) ? "default" : "outline"}
|
|
>
|
|
<span className="text-sm">{label?.substring(0, 3)}</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
const MAX_SLIDER_VALUE = 1500;
|
|
const BudgetFilters = () => {
|
|
const { t } = useTranslation();
|
|
const { minBudget, maxBudget, setAllParams } = useRicerca();
|
|
|
|
const minDebounced = useCallback(
|
|
debounce(async (value: number | null) => {
|
|
await setAllParams({
|
|
minBudget: value,
|
|
});
|
|
}, 500),
|
|
[setAllParams],
|
|
);
|
|
|
|
const maxDebounced = useCallback(
|
|
debounce(async (value: number | null) => {
|
|
await setAllParams({
|
|
maxBudget: value,
|
|
});
|
|
}, 500),
|
|
[setAllParams],
|
|
);
|
|
|
|
const [sliderValue, setSliderValue] = useState<[number, number]>([
|
|
Number(minBudget) / 100 || 0,
|
|
Number(maxBudget) / 100 || MAX_SLIDER_VALUE,
|
|
]);
|
|
|
|
const onSliderChangeEnd = () => {
|
|
const [min, max] = sliderValue;
|
|
minDebounced(min === 0 ? null : min * 100);
|
|
maxDebounced(max === MAX_SLIDER_VALUE ? null : max * 100);
|
|
};
|
|
useEffect(() => {
|
|
setSliderValue([
|
|
Number(minBudget) / 100 || 0,
|
|
Number(maxBudget) / 100 || MAX_SLIDER_VALUE,
|
|
]);
|
|
}, [minBudget, maxBudget]);
|
|
|
|
return (
|
|
<>
|
|
<div className="flex w-full flex-col gap-2">
|
|
<div className="flex flex-row items-center gap-4">
|
|
<Label
|
|
className={cn("font-semibold text-muted-foreground")}
|
|
htmlFor="slider"
|
|
>
|
|
{t.annunci.budget}
|
|
</Label>
|
|
{(minBudget !== null || maxBudget !== null) && (
|
|
<button
|
|
className="cursor-pointer"
|
|
onClick={async () => {
|
|
await setAllParams({
|
|
minBudget: null,
|
|
maxBudget: null,
|
|
});
|
|
}}
|
|
type="button"
|
|
>
|
|
<RotateCcwIcon className="size-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<Slider
|
|
className="mt-2"
|
|
id="slider"
|
|
max={MAX_SLIDER_VALUE}
|
|
min={0}
|
|
onValueChange={(value) => setSliderValue(value as [number, number])}
|
|
onValueCommit={onSliderChangeEnd}
|
|
step={100}
|
|
value={sliderValue}
|
|
/>
|
|
<div className="flex items-center justify-between text-muted-foreground text-sm">
|
|
<span>{sliderValue[0]} €</span>
|
|
<span>
|
|
{sliderValue[1] === MAX_SLIDER_VALUE ? "∞" : sliderValue[1]} €
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|