infoalloggi-monorepo/apps/infoalloggi/src/pages/annunci.tsx

617 lines
14 KiB
TypeScript
Raw Normal View History

2025-08-28 18:27:07 +02:00
import { keepPreviousData } from "@tanstack/react-query";
import {
ChevronDown,
Filter,
FilterX,
MapIcon,
RotateCcwIcon,
Table,
} from "lucide-react";
2025-08-04 17:45:44 +02:00
import type { GetServerSideProps } from "next";
import dynamic from "next/dynamic";
2025-08-04 17:45:44 +02:00
import Head from "next/head";
import { useCallback, useMemo } from "react";
2025-08-28 18:27:07 +02:00
import { AccordionComp } from "~/components/accordionComp";
import { CTA_TipologiaModal } from "~/components/annunci_tutorial";
import { LazyCardAnnuncio } from "~/components/annuncio_card";
2025-08-28 18:27:07 +02:00
import { CodiceBox } from "~/components/codiceRicerca";
import { NumberInput } from "~/components/custom_ui/InputNumber";
import { Layout } from "~/components/Layout";
2025-08-04 17:45:44 +02:00
import { LoadingPage } from "~/components/loading";
import ScrollToTop from "~/components/scroll_top";
2025-08-28 18:27:07 +02:00
import { Status500 } from "~/components/status-page";
import { Badge } from "~/components/ui/badge";
2025-08-04 17:45:44 +02:00
import { Button } from "~/components/ui/button";
2025-08-28 18:27:07 +02:00
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
2025-08-04 17:45:44 +02:00
import { Label } from "~/components/ui/label";
import {
2025-08-28 18:27:07 +02:00
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { useMediaQuery } from "~/hooks/use-media-query";
import { cn, debounce } from "~/lib/utils";
2025-08-04 17:45:44 +02:00
import { useTranslation } from "~/providers/I18nProvider";
import {
2025-08-28 18:27:07 +02:00
type AnnunciOptions,
RicercaProvider,
type SortTypes,
useRicerca,
2025-08-04 17:45:44 +02:00
} from "~/providers/RicercaProvider";
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
const MapSection = dynamic(
() =>
import("~/components/annunci_map").then((mod) => ({
default: mod.MapSection,
})),
{
ssr: false,
loading: () => <LoadingPage />,
},
);
2025-08-04 17:45:44 +02:00
type AnnunciPageProps = {
2025-08-28 18:27:07 +02:00
options: AnnunciOptions;
2025-08-04 17:45:44 +02:00
};
const Annunci = ({ options }: AnnunciPageProps) => {
2025-08-28 18:27:07 +02:00
const { t } = useTranslation();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
return (
<RicercaProvider options={options}>
<Head>
<title>{t.heads.annunci_titolo}</title>
2025-08-29 16:18:32 +02:00
<meta content={t.heads.annunci_description} name="description" />
2025-08-28 18:27:07 +02:00
</Head>
2025-08-04 17:45:44 +02:00
<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>
<ScrollToTop />
2025-08-28 18:27:07 +02:00
<Ricerca />
<AccordionComp
className="max-w-6xl px-4 py-4 md:py-8"
defaultOpen={0}
2025-08-29 16:18:32 +02:00
texts={t.annunci.accordion_minifaq}
2025-08-28 18:27:07 +02:00
/>
</main>
</RicercaProvider>
);
2025-08-04 17:45:44 +02:00
};
2025-08-04 17:45:44 +02:00
export default Annunci;
Annunci.getLayout = (page: React.ReactNode) => {
return <Layout>{page}</Layout>;
};
export const getServerSideProps = (async () => {
2025-08-28 18:27:07 +02:00
const helper = generateSSGHelper();
const options = await helper.annunci.getAnnunciOptions.fetch();
return {
props: {
options,
},
};
2025-08-04 17:45:44 +02:00
}) satisfies GetServerSideProps<AnnunciPageProps>;
const Ricerca = () => {
const { map, isLoading } = useRicerca();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
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 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={["Recenti", "Prezzo", "Consegna", "Mq"]}
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-46 max-w-46 shrink-0 grow-0 flex-col gap-3 overflow-clip rounded-md bg-secondary p-2 outline-neutral-300">
<Filters />
</div>
2025-08-28 18:27:07 +02:00
);
2025-08-04 17:45:44 +02:00
};
const DrowdownFilters = () => {
2025-08-28 18:27:07 +02:00
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-60 max-w-3xl flex-col gap-2 p-4"
>
<Filters />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
const RicercaHeader = () => {
const { map, setAllParams } = useRicerca();
2025-08-28 18:27:07 +02:00
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 />
2025-08-28 18:27:07 +02:00
<Button
className="flex w-fit flex-row items-center gap-2 md:w-24"
2025-08-28 18:27:07 +02:00
onClick={async () => {
await setAllParams({
map: !map,
});
2025-08-28 18:27:07 +02:00
}}
2025-08-29 16:18:32 +02:00
variant="default"
2025-08-28 18:27:07 +02:00
>
{!map ? (
<>
<MapIcon /> <span className="hidden sm:block">Mappa</span>
2025-08-28 18:27:07 +02:00
</>
) : (
<>
<Table />
2025-08-28 18:27:07 +02:00
<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 "
/>
2025-08-28 18:27:07 +02:00
</div>
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
<CTA_TipologiaModal />
</div>
</div>
</div>
);
2025-08-04 17:45:44 +02:00
};
const AnnunciList = () => {
2025-08-28 18:27:07 +02:00
const {
tipo,
comune,
consegna,
sort,
map,
minBudget,
maxBudget,
isLoading: isUpdatingRicerca,
2025-08-28 18:27:07 +02:00
} = useRicerca();
2025-08-04 17:45:44 +02:00
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 />;
2025-08-28 18:27:07 +02:00
if (status === "error") return <Status500 />;
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
return (
<div className="w-full space-y-4">
2025-08-28 18:27:07 +02:00
{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>
2025-08-28 18:27:07 +02:00
)}
</div>
2025-08-28 18:27:07 +02:00
);
2025-08-04 17:45:44 +02:00
};
interface SelectFilterProps<T> {
2025-08-28 18:27:07 +02:00
label: string;
id: string;
2025-08-28 18:27:07 +02:00
options: string[];
defaultValue?: T;
value: T | undefined;
onValueChange: (value: T) => void;
handleReset: () => void;
placeholder: string;
2025-08-04 17:45:44 +02:00
}
export const SelectFilter = <T extends string>({
2025-08-28 18:27:07 +02:00
label,
id,
2025-08-28 18:27:07 +02:00
options,
defaultValue,
value,
onValueChange,
placeholder,
handleReset,
2025-08-04 17:45:44 +02:00
}: SelectFilterProps<T>) => {
2025-08-28 18:27:07 +02:00
return (
<div className="flex flex-col gap-2">
<div className="flex h-4 flex-row gap-4 px-1">
<Label
className={cn(
2025-10-10 16:18:43 +02:00
"font-semibold text-muted-foreground",
value !== defaultValue && "text-primary",
2025-08-28 18:27:07 +02:00
)}
htmlFor={id}
2025-08-28 18:27:07 +02:00
>
{label}
</Label>
{value !== defaultValue && (
<button
className="cursor-pointer"
onClick={handleReset}
type="button"
>
2025-08-28 18:27:07 +02:00
<RotateCcwIcon className="size-4" />
</button>
)}
</div>
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
<Select
defaultValue={defaultValue}
onValueChange={onValueChange}
2025-08-29 16:18:32 +02:00
value={value}
2025-08-28 18:27:07 +02:00
>
<SelectTrigger
className="w-full bg-primary-foreground text-primary"
id={id}
>
2025-08-28 18:27:07 +02:00
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
2025-08-29 16:18:32 +02:00
<SelectItem className="text-left" key={option} value={option}>
2025-08-28 18:27:07 +02:00
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
2025-08-04 17:45:44 +02:00
};
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: c, label: t.parametri.mesi[c] } : 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 }) => (
<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, 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],
);
return (
<>
{/*
<div className="flex w-full max-w-md flex-col gap-2">
<Label htmlFor="slider">Price Range</Label>
<Slider id="slider" max={1000} min={0} onValueChange={setValue} value={value} />
<div className="flex items-center justify-between text-muted-foreground text-sm">
<span>${value[0]}</span>
<span>${value[1]}</span>
</div>
</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",
minBudget !== null && "text-primary",
)}
htmlFor="prezzoMIN"
>
{t.annunci.budgetMin}
</Label>
{minBudget !== null && (
<button
className="cursor-pointer"
onClick={async () => {
await setAllParams({
minBudget: null,
});
}}
type="button"
>
<RotateCcwIcon className="size-4" />
</button>
)}
</div>
<NumberInput
className="bg-background"
decimalScale={2}
decimalSeparator=","
fixedDecimalScale
id="prezzoMIN"
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",
)}
htmlFor="prezzoMAX"
>
{t.annunci.budgetMax}
</Label>
{maxBudget !== null && (
<button
className="cursor-pointer"
onClick={async () => {
await setAllParams({
maxBudget: null,
});
}}
type="button"
>
<RotateCcwIcon className="size-4" />
</button>
)}
</div>
<NumberInput
className="bg-background"
decimalScale={2}
decimalSeparator=","
fixedDecimalScale
id="prezzoMAX"
min={0}
onValueChange={(v) => {
if (v && v > 0) {
maxDebounced(v * 100);
} else {
maxDebounced(null);
}
}}
stepper={100}
suffix=" €"
value={Number(maxBudget) / 100}
/>
</div>
</>
);
};