581 lines
13 KiB
TypeScript
581 lines
13 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, useMemo } 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 { NumberInput } from "~/components/custom_ui/InputNumber";
|
|
import { Layout } from "~/components/Layout";
|
|
import { LoadingPage } from "~/components/loading";
|
|
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 { useMediaQuery } from "~/hooks/use-media-query";
|
|
import { cn, debounce } from "~/lib/utils";
|
|
import { useTranslation } from "~/providers/I18nProvider";
|
|
import {
|
|
type AnnunciOptions,
|
|
compatibilityPatch,
|
|
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-10xl 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>
|
|
|
|
<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 (ctx) => {
|
|
const helper = generateSSGHelper();
|
|
const options = await helper.annunci.getAnnunciOptions.fetch();
|
|
const {patched, query} = compatibilityPatch(ctx.query);
|
|
if (patched){
|
|
const queryString = new URLSearchParams(query as Record<string, string>).toString();
|
|
return {
|
|
redirect: {
|
|
destination: `/annunci?${queryString}`,
|
|
permanent: false,
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
props: {
|
|
options,
|
|
},
|
|
};
|
|
}) satisfies GetServerSideProps<AnnunciPageProps>;
|
|
|
|
const Ricerca = () => {
|
|
const { map } = 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">{map ? <MapSection /> : <AnnunciList />}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Filters = () => {
|
|
const { t } = useTranslation();
|
|
const {
|
|
tipo,
|
|
setTipo,
|
|
comune,
|
|
setComune,
|
|
sort,
|
|
setSort,
|
|
comuniOptions,
|
|
tipologieOptions,
|
|
ResetParams,
|
|
nFiltri,
|
|
} = useRicerca();
|
|
|
|
return (
|
|
<>
|
|
<SelectFilter
|
|
defaultValue={""}
|
|
handleReset={async () => {
|
|
await setTipo("");
|
|
}}
|
|
label={t.annunci.fs_tipologia.titolo}
|
|
onValueChange={async (value: string) => {
|
|
await setTipo(value);
|
|
}}
|
|
options={tipologieOptions}
|
|
placeholder={t.seleziona_placeholder}
|
|
value={tipo}
|
|
/>
|
|
<SelectFilter
|
|
defaultValue={""}
|
|
handleReset={async () => {
|
|
await setComune(null);
|
|
}}
|
|
label={t.annunci.comune}
|
|
onValueChange={async (value: string) => {
|
|
await setComune(value);
|
|
}}
|
|
options={comuniOptions}
|
|
placeholder={t.seleziona_placeholder}
|
|
value={comune}
|
|
/>
|
|
<ConsegnaFilter />
|
|
<BudgetFilters />
|
|
<SelectFilter
|
|
defaultValue={sort}
|
|
handleReset={async () => {
|
|
await setSort("Recenti");
|
|
}}
|
|
label={t.ordina_per}
|
|
onValueChange={async (value: SortTypes) => {
|
|
await setSort(value);
|
|
}}
|
|
options={["Recenti", "Prezzo", "Consegna", "Mq"]}
|
|
placeholder={t.seleziona_placeholder}
|
|
value={sort}
|
|
/>
|
|
<Button
|
|
className="flex flex-row gap-2"
|
|
disabled={nFiltri === 0}
|
|
onClick={ResetParams}
|
|
variant="destructive"
|
|
>
|
|
<FilterX className="size-5" /> {t.annunci.filtri_reset}
|
|
</Button>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const RicercaSideFilters = () => {
|
|
return (
|
|
<div className="hidden h-fit w-46 max-w-46 shrink-0 grow-0 flex-col gap-3 overflow-clip rounded-md bg-secondary p-2 outline-neutral-300 2xl:flex">
|
|
<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 className="size-5" />{" "}
|
|
{nFiltri === 1 ? t.annunci.filtro_attivo : t.annunci.filtri_attivi}
|
|
<ChevronDown />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className="flex w-60 max-w-3xl flex-col gap-2 p-4"
|
|
>
|
|
<Filters />
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const RicercaHeader = () => {
|
|
const { map, setMap } = useRicerca();
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-10xl 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 setMap((v) => !v);
|
|
}}
|
|
variant="default"
|
|
>
|
|
{!map ? (
|
|
<>
|
|
<MapIcon className="size-5" />{" "}
|
|
<span className="hidden sm:block">Mappa</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Table className="size-5" />
|
|
<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: ricercaLoading,
|
|
} = useRicerca();
|
|
|
|
const { data, status } = api.annunci.annunciRicerca.useQuery(
|
|
{
|
|
comune: comune || undefined,
|
|
consegna: consegna || undefined,
|
|
sort: sort || undefined,
|
|
tipologia: tipo || undefined,
|
|
minBudget: minBudget || undefined,
|
|
maxBudget: maxBudget || undefined,
|
|
},
|
|
{
|
|
enabled: !map,
|
|
placeholderData: keepPreviousData,
|
|
staleTime: 5000,
|
|
},
|
|
);
|
|
|
|
if (ricercaLoading) 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;
|
|
options: string[];
|
|
defaultValue?: T;
|
|
value: T | undefined;
|
|
onValueChange: (value: T) => void;
|
|
handleReset: () => void;
|
|
placeholder: string;
|
|
}
|
|
|
|
export const SelectFilter = <T extends string>({
|
|
label,
|
|
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",
|
|
)}
|
|
>
|
|
{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">
|
|
<SelectValue placeholder={placeholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((option) => (
|
|
<SelectItem className="text-left" key={option} value={option}>
|
|
{option}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ConsegnaFilter = () => {
|
|
const { t } = useTranslation();
|
|
const {
|
|
consegna: consegna2,
|
|
setConsegna: setConsegna2,
|
|
|
|
consegnaOptions,
|
|
} = useRicerca();
|
|
|
|
const mappedConsegnaOptions = useMemo(() => {
|
|
if (consegnaOptions.length === 0) return [];
|
|
return consegnaOptions
|
|
.map((c) =>
|
|
t.parametri.mesi[c] ? { value: c, label: t.parametri.mesi[c] } : null,
|
|
)
|
|
.filter((v) => v !== null);
|
|
}, [consegnaOptions, t.parametri.mesi]);
|
|
|
|
const onValueChange = async (value: number) => {
|
|
if (consegna2?.includes(value)) {
|
|
// Remove value
|
|
const newConsegna2 = consegna2.filter((c) => c !== value);
|
|
await setConsegna2(newConsegna2.length > 0 ? newConsegna2 : null);
|
|
return;
|
|
}
|
|
// Add value
|
|
const newConsegna2 = consegna2 ? [...consegna2, value] : [value];
|
|
await setConsegna2(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 setConsegna2(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 }) => (
|
|
<Button
|
|
className={cn(
|
|
consegna2?.includes(value) && "border border-primary",
|
|
)}
|
|
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 BudgetFilters = () => {
|
|
const { t } = useTranslation();
|
|
const { minBudget, setMinBudget, maxBudget, setMaxBudget } = useRicerca();
|
|
|
|
const minDebounced = useCallback(
|
|
debounce(async (value: number | null) => {
|
|
await setMinBudget(value);
|
|
}, 500),
|
|
[setMinBudget],
|
|
);
|
|
|
|
const maxDebounced = useCallback(
|
|
debounce(async (value: number | null) => {
|
|
await setMaxBudget(value);
|
|
}, 500),
|
|
[setMaxBudget],
|
|
);
|
|
|
|
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",
|
|
minBudget !== null && "text-primary",
|
|
)}
|
|
>
|
|
{t.annunci.budgetMin}
|
|
</Label>
|
|
{minBudget !== null && (
|
|
<button
|
|
className="cursor-pointer"
|
|
onClick={async () => {
|
|
await setMinBudget(null);
|
|
}}
|
|
type="button"
|
|
>
|
|
<RotateCcwIcon className="size-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<NumberInput
|
|
className="bg-background"
|
|
decimalScale={2}
|
|
decimalSeparator=","
|
|
fixedDecimalScale
|
|
id="prezzo"
|
|
min={0}
|
|
onValueChange={(v) => {
|
|
if (v && v > 0) {
|
|
minDebounced(v * 100);
|
|
} else {
|
|
minDebounced(null);
|
|
}
|
|
}}
|
|
stepper={100}
|
|
suffix=" €"
|
|
value={Number(minBudget) / 100}
|
|
/>
|
|
</div>
|
|
|
|
<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",
|
|
maxBudget !== null && "text-primary",
|
|
)}
|
|
>
|
|
{t.annunci.budgetMax}
|
|
</Label>
|
|
{maxBudget !== null && (
|
|
<button
|
|
className="cursor-pointer"
|
|
onClick={async () => {
|
|
await setMaxBudget(null);
|
|
}}
|
|
type="button"
|
|
>
|
|
<RotateCcwIcon className="size-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<NumberInput
|
|
className="bg-background"
|
|
decimalScale={2}
|
|
decimalSeparator=","
|
|
fixedDecimalScale
|
|
id="prezzo"
|
|
min={0}
|
|
onValueChange={(v) => {
|
|
if (v && v > 0) {
|
|
maxDebounced(v * 100);
|
|
} else {
|
|
maxDebounced(null);
|
|
}
|
|
}}
|
|
stepper={100}
|
|
suffix=" €"
|
|
value={Number(maxBudget) / 100}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|