feat: refactor Slider component and update budget filters for improved functionality and responsiveness

This commit is contained in:
Marco Pedone 2026-02-03 12:54:41 +01:00
parent 1e06fd5584
commit 9f02c49193
8 changed files with 134 additions and 140 deletions

View file

@ -3,26 +3,23 @@ import * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface SliderProps
extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {
rangeClassName?: string;
thumbClassName?: string;
}
function Slider({ function Slider({
className, className,
thumbClassName,
rangeClassName,
defaultValue, defaultValue,
value, value,
min = 0, min = 0,
max = 100, max = 100,
...props ...props
}: React.ComponentProps<typeof SliderPrimitive.Root> & SliderProps) { }: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(() => { const _values = React.useMemo(
if (Array.isArray(value)) return value; () =>
if (Array.isArray(defaultValue)) return defaultValue; Array.isArray(value)
return [min, max]; ? value
}, [value, defaultValue, min, max]); : Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return ( return (
<SliderPrimitive.Root <SliderPrimitive.Root
@ -39,26 +36,22 @@ function Slider({
> >
<SliderPrimitive.Track <SliderPrimitive.Track
className={cn( className={cn(
"relative grow cursor-pointer overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5", "relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5",
)} )}
data-slot="slider-track" data-slot="slider-track"
> >
<SliderPrimitive.Range <SliderPrimitive.Range
className={cn( className={cn(
"absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full", "absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
rangeClassName,
)} )}
data-slot="slider-range" data-slot="slider-range"
/> />
</SliderPrimitive.Track> </SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => ( {Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb <SliderPrimitive.Thumb
className={cn( className="block size-4 shrink-0 cursor-pointer rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:outline-hidden focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
"block size-4 shrink-0 cursor-pointer rounded-full border border-primary bg-background shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:outline-hidden focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50",
thumbClassName,
)}
data-slot="slider-thumb" data-slot="slider-thumb"
// biome-ignore lint/suspicious/noArrayIndexKey: <index is safe here> // biome-ignore lint/suspicious/noArrayIndexKey: <ok>
key={index} key={index}
/> />
))} ))}

View file

@ -208,9 +208,7 @@ export const FormNewServizio = ({
onValueChange={(vals) => { onValueChange={(vals) => {
field.onChange(Number(vals[0])); field.onChange(Number(vals[0]));
}} }}
rangeClassName="bg-primary"
step={50} step={50}
thumbClassName="border-primary"
value={[form.getValues("budget")]} value={[form.getValues("budget")]}
/> />
</FormControl> </FormControl>

View file

@ -582,9 +582,7 @@ export const FormNewServizioAcquisto = ({
onValueChange={(vals) => { onValueChange={(vals) => {
field.onChange(Number(vals[0])); field.onChange(Number(vals[0]));
}} }}
rangeClassName="bg-neutral-700"
step={50} step={50}
thumbClassName="border-neutral-700"
value={[budget]} value={[budget]}
/> />
</FormControl> </FormControl>

View file

@ -283,8 +283,7 @@ export const en: LangDict = {
], ],
comune: "City", comune: "City",
consegna: "From", consegna: "From",
budgetMin: "Minimum Budget", budget: "Budget",
budgetMax: "Maximum Budget",
filtri_attivi: "Active filters", filtri_attivi: "Active filters",
filtri_reset: "Reset filters", filtri_reset: "Reset filters",
filtro_attivo: "Active filter", filtro_attivo: "Active filter",

View file

@ -288,8 +288,7 @@ export const it: LangDict = {
], ],
comune: "Comune", comune: "Comune",
consegna: "Disponibile da", consegna: "Disponibile da",
budgetMin: "Budget Minimo", budget: "Budget",
budgetMax: "Budget Massimo",
filtri_attivi: "Filtri attivi", filtri_attivi: "Filtri attivi",
filtri_reset: "Reset filtri", filtri_reset: "Reset filtri",
filtro_attivo: "Filtro attivo", filtro_attivo: "Filtro attivo",

View file

@ -250,8 +250,7 @@ export type LangDict = {
comune: string; comune: string;
consegna: string; consegna: string;
budgetMin: string; budget: string;
budgetMax: string;
accordion_minifaq: Faq[]; accordion_minifaq: Faq[];
find_similar: string; find_similar: string;
codice: string; codice: string;

View file

@ -10,12 +10,11 @@ import {
import type { GetServerSideProps } from "next"; import type { GetServerSideProps } from "next";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Head from "next/head"; import Head from "next/head";
import { useCallback, useMemo } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { AccordionComp } from "~/components/accordionComp"; import { AccordionComp } from "~/components/accordionComp";
import { CTA_TipologiaModal } from "~/components/annunci_tutorial"; import { CTA_TipologiaModal } from "~/components/annunci_tutorial";
import { LazyCardAnnuncio } from "~/components/annuncio_card"; import { LazyCardAnnuncio } from "~/components/annuncio_card";
import { CodiceBox } from "~/components/codiceRicerca"; import { CodiceBox } from "~/components/codiceRicerca";
import { NumberInput } from "~/components/custom_ui/InputNumber";
import { Layout } from "~/components/Layout"; import { Layout } from "~/components/Layout";
import { LoadingPage } from "~/components/loading"; import { LoadingPage } from "~/components/loading";
import ScrollToTop from "~/components/scroll_top"; import ScrollToTop from "~/components/scroll_top";
@ -35,11 +34,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Slider } from "~/components/ui/slider";
import { useMediaQuery } from "~/hooks/use-media-query"; import { useMediaQuery } from "~/hooks/use-media-query";
import { cn, debounce } from "~/lib/utils"; import { cn, debounce } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
import { import {
type AnnunciOptions, type AnnunciOptions,
type FilterOption,
RicercaProvider, RicercaProvider,
type SortTypes, type SortTypes,
useRicerca, useRicerca,
@ -72,7 +73,7 @@ const Annunci = ({ options }: AnnunciPageProps) => {
<meta content={t.heads.annunci_description} name="description" /> <meta content={t.heads.annunci_description} name="description" />
</Head> </Head>
<main className="relative mx-auto w-full max-w-10xl space-y-5 bg-background px-2 py-5 md:px-8"> <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"> <Badge className="bg-muted py-1 text-foreground text-sm outline outline-muted-foreground">
{t.annunci.titolo} {t.annunci.titolo}
</Badge> </Badge>
@ -121,6 +122,13 @@ const Ricerca = () => {
); );
}; };
const sort_options: FilterOption<SortTypes>[] = [
{ value: "Recenti", disabled: false },
{ value: "Prezzo", disabled: false },
{ value: "Consegna", disabled: false },
{ value: "Mq", disabled: false },
];
const Filters = () => { const Filters = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
@ -189,7 +197,7 @@ const Filters = () => {
sort: value, sort: value,
}); });
}} }}
options={["Recenti", "Prezzo", "Consegna", "Mq"]} options={sort_options}
placeholder={t.seleziona_placeholder} placeholder={t.seleziona_placeholder}
value={sort} value={sort}
/> />
@ -210,7 +218,7 @@ const RicercaSideFilters = () => {
if (!isBigScreen) return null; if (!isBigScreen) return null;
return ( 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"> <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 /> <Filters />
</div> </div>
); );
@ -240,7 +248,7 @@ const DrowdownFilters = () => {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className="flex w-60 max-w-3xl flex-col gap-2 p-4" className="flex w-full min-w-80 max-w-3xl flex-col gap-2 p-4"
> >
<Filters /> <Filters />
</DropdownMenuContent> </DropdownMenuContent>
@ -253,7 +261,7 @@ const RicercaHeader = () => {
const { map, setAllParams } = useRicerca(); const { map, setAllParams } = useRicerca();
return ( return (
<div className="mx-auto w-full max-w-10xl space-y-5"> <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 flex-col-reverse gap-2 sm:flex-row">
<div className="flex w-full items-center gap-2 sm:w-auto"> <div className="flex w-full items-center gap-2 sm:w-auto">
<DrowdownFilters /> <DrowdownFilters />
@ -348,7 +356,7 @@ const AnnunciList = () => {
interface SelectFilterProps<T> { interface SelectFilterProps<T> {
label: string; label: string;
id: string; id: string;
options: string[]; options: FilterOption<string>[];
defaultValue?: T; defaultValue?: T;
value: T | undefined; value: T | undefined;
onValueChange: (value: T) => void; onValueChange: (value: T) => void;
@ -402,8 +410,13 @@ export const SelectFilter = <T extends string>({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{options.map((option) => ( {options.map((option) => (
<SelectItem className="text-left" key={option} value={option}> <SelectItem
{option} className="text-left"
disabled={option.disabled}
key={option.value}
value={option.value}
>
{option.value}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -420,7 +433,13 @@ const ConsegnaFilter = () => {
if (consegnaOptions.length === 0) return []; if (consegnaOptions.length === 0) return [];
return consegnaOptions return consegnaOptions
.map((c) => .map((c) =>
t.parametri.mesi[c] ? { value: c, label: t.parametri.mesi[c] } : null, t.parametri.mesi[c.value]
? {
value: c.value,
label: t.parametri.mesi[c.value],
disabled: c.disabled,
}
: null,
) )
.filter((v) => v !== null); .filter((v) => v !== null);
}, [consegnaOptions, t.parametri.mesi]); }, [consegnaOptions, t.parametri.mesi]);
@ -467,11 +486,12 @@ const ConsegnaFilter = () => {
</div> </div>
<div className="flex flex-wrap gap-1 rounded-md bg-background p-1"> <div className="flex flex-wrap gap-1 rounded-md bg-background p-1">
{mappedConsegnaOptions.map(({ value, label }) => ( {mappedConsegnaOptions.map(({ value, label, disabled }) => (
<Button <Button
className={cn( className={cn(
consegna2?.includes(value) && "border border-primary", consegna2?.includes(value) && "border border-primary",
)} )}
disabled={disabled && !consegna2?.includes(value)}
key={value} key={value}
onClick={async () => { onClick={async () => {
await onValueChange(value); await onValueChange(value);
@ -479,14 +499,14 @@ const ConsegnaFilter = () => {
size="sm" size="sm"
variant={consegna2?.includes(value) ? "default" : "outline"} variant={consegna2?.includes(value) ? "default" : "outline"}
> >
<span className="text-sm">{label.substring(0, 3)}</span> <span className="text-sm">{label?.substring(0, 3)}</span>
</Button> </Button>
))} ))}
</div> </div>
</div> </div>
); );
}; };
const MAX_SLIDER_VALUE = 1500;
const BudgetFilters = () => { const BudgetFilters = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { minBudget, maxBudget, setAllParams } = useRicerca(); const { minBudget, maxBudget, setAllParams } = useRicerca();
@ -509,80 +529,39 @@ const BudgetFilters = () => {
[setAllParams], [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 ( return (
<> <>
{/* <div className="flex w-full flex-col gap-2">
<div className="flex w-full max-w-md flex-col gap-2"> <div className="flex flex-row items-center gap-4">
<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 <Label
className={cn( className={cn("font-semibold text-muted-foreground")}
"font-semibold text-muted-foreground", htmlFor="slider"
minBudget !== null && "text-primary",
)}
htmlFor="prezzoMIN"
> >
{t.annunci.budgetMin} {t.annunci.budget}
</Label> </Label>
{minBudget !== null && ( {(minBudget !== null || maxBudget !== null) && (
<button <button
className="cursor-pointer" className="cursor-pointer"
onClick={async () => { onClick={async () => {
await setAllParams({ await setAllParams({
minBudget: null, 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, maxBudget: null,
}); });
}} }}
@ -592,24 +571,22 @@ const BudgetFilters = () => {
</button> </button>
)} )}
</div> </div>
<NumberInput <Slider
className="bg-background" className="mt-2"
decimalScale={2} id="slider"
decimalSeparator="," max={MAX_SLIDER_VALUE}
fixedDecimalScale
id="prezzoMAX"
min={0} min={0}
onValueChange={(v) => { onValueChange={(value) => setSliderValue(value as [number, number])}
if (v && v > 0) { onValueCommit={onSliderChangeEnd}
maxDebounced(v * 100); step={100}
} else { value={sliderValue}
maxDebounced(null);
}
}}
stepper={100}
suffix=" €"
value={Number(maxBudget) / 100}
/> />
<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> </div>
</> </>
); );

View file

@ -52,6 +52,11 @@ export const buildRicercaUrl = (params: Partial<RicercaUrlParams>): string => {
return queryString ? `/annunci?${queryString}` : "/annunci"; return queryString ? `/annunci?${queryString}` : "/annunci";
}; };
export type FilterOption<T> = {
value: T;
disabled: boolean;
};
type RicercaContextType = { type RicercaContextType = {
map: boolean; map: boolean;
tipo: RicercaUrlParams["tipo"]; tipo: RicercaUrlParams["tipo"];
@ -74,9 +79,9 @@ type RicercaContextType = {
) => Promise<URLSearchParams>; ) => Promise<URLSearchParams>;
reset: () => void; reset: () => void;
comuniOptions: string[]; comuniOptions: FilterOption<string>[];
tipologieOptions: string[]; tipologieOptions: FilterOption<string>[];
consegnaOptions: number[]; consegnaOptions: FilterOption<number>[];
sortOptions: typeof sortOptions; sortOptions: typeof sortOptions;
nFiltri: number; nFiltri: number;
isLoading: boolean; isLoading: boolean;
@ -133,11 +138,24 @@ export const RicercaProvider: FC<{
}, []); }, []);
const { tipologieOptions, comuniOptions, consegnaOptions } = useMemo(() => { const { tipologieOptions, comuniOptions, consegnaOptions } = useMemo(() => {
const typeSet = new Set<string>(); // const typeSet = new Set<string>();
const comuneSet = new Set<string>(); // const comuneSet = new Set<string>();
const consegnaSet = new Set<number>(); // const consegnaSet = new Set<number>();
// 1. Sets for ALL distinct values present in the data (The Universe)
const allTypes = new Set<string>();
const allComuni = new Set<string>();
const allConsegna = new Set<number>();
// 2. Sets for values available with CURRENT OTHER filters (The Subset)
const availableTypes = new Set<string>();
const availableComuni = new Set<string>();
const availableConsegna = new Set<number>();
for (const item of options) { for (const item of options) {
// Populate the Universe sets
allTypes.add(item.tipo);
allComuni.add(item.comune);
allConsegna.add(item.consegna);
const matchesTipo = !tipo || item.tipo === tipo; const matchesTipo = !tipo || item.tipo === tipo;
const matchesComune = !comune || item.comune === comune; const matchesComune = !comune || item.comune === comune;
const matchesConsegna = !consegna || consegna.includes(item.consegna); const matchesConsegna = !consegna || consegna.includes(item.consegna);
@ -147,30 +165,43 @@ export const RicercaProvider: FC<{
// Add to Tipologie if it matches everything ELSE (Comune, Consegna, Budget) // Add to Tipologie if it matches everything ELSE (Comune, Consegna, Budget)
if (matchesComune && matchesConsegna && matchesBudget) { if (matchesComune && matchesConsegna && matchesBudget) {
typeSet.add(item.tipo); availableTypes.add(item.tipo);
} }
// Add to Comuni if it matches everything ELSE (Tipo, Consegna, Budget) // Add to Comuni if it matches everything ELSE (Tipo, Consegna, Budget)
if (matchesTipo && matchesConsegna && matchesBudget) { if (matchesTipo && matchesConsegna && matchesBudget) {
comuneSet.add(item.comune); availableComuni.add(item.comune);
} }
// Add to Consegna if it matches everything ELSE (Tipo, Comune, Budget) // Add to Consegna if it matches everything ELSE (Tipo, Comune, Budget)
if (matchesTipo && matchesComune && matchesBudget) { if (matchesTipo && matchesComune && matchesBudget) {
consegnaSet.add(item.consegna); availableConsegna.add(item.consegna);
} }
} }
const currentMonth = new Date().getMonth() + 1; // 1-12 const currentMonth = new Date().getMonth() + 1; // 1-12
return { return {
tipologieOptions: Array.from(typeSet), tipologieOptions: Array.from(allTypes).map((t) => ({
comuniOptions: Array.from(comuneSet).sort((a, b) => a.localeCompare(b)), value: t,
consegnaOptions: Array.from(consegnaSet).sort((a, b) => { disabled: !availableTypes.has(t),
})),
comuniOptions: Array.from(allComuni)
.sort((a, b) => a.localeCompare(b))
.map((c) => ({
value: c,
disabled: !availableComuni.has(c),
})),
consegnaOptions: Array.from(allConsegna)
.sort((a, b) => {
const distA = (a - currentMonth + 12) % 12; const distA = (a - currentMonth + 12) % 12;
const distB = (b - currentMonth + 12) % 12; const distB = (b - currentMonth + 12) % 12;
return distA - distB; return distA - distB;
}), })
.map((c) => ({
value: c,
disabled: !availableConsegna.has(c),
})),
}; };
}, [options, tipo, comune, consegna, minBudget, maxBudget]); }, [options, tipo, comune, consegna, minBudget, maxBudget]);