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";
interface SliderProps
extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {
rangeClassName?: string;
thumbClassName?: string;
}
function Slider({
className,
thumbClassName,
rangeClassName,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root> & SliderProps) {
const _values = React.useMemo(() => {
if (Array.isArray(value)) return value;
if (Array.isArray(defaultValue)) return defaultValue;
return [min, max];
}, [value, defaultValue, min, max]);
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
@ -39,26 +36,22 @@ function Slider({
>
<SliderPrimitive.Track
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"
>
<SliderPrimitive.Range
className={cn(
"absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
rangeClassName,
)}
data-slot="slider-range"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
className={cn(
"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,
)}
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"
data-slot="slider-thumb"
// biome-ignore lint/suspicious/noArrayIndexKey: <index is safe here>
// biome-ignore lint/suspicious/noArrayIndexKey: <ok>
key={index}
/>
))}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,12 +10,11 @@ import {
import type { GetServerSideProps } from "next";
import dynamic from "next/dynamic";
import Head from "next/head";
import { useCallback, useMemo } from "react";
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 { NumberInput } from "~/components/custom_ui/InputNumber";
import { Layout } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import ScrollToTop from "~/components/scroll_top";
@ -35,11 +34,13 @@ import {
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,
@ -72,7 +73,7 @@ const Annunci = ({ options }: AnnunciPageProps) => {
<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">
<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>
@ -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 { t } = useTranslation();
const {
@ -189,7 +197,7 @@ const Filters = () => {
sort: value,
});
}}
options={["Recenti", "Prezzo", "Consegna", "Mq"]}
options={sort_options}
placeholder={t.seleziona_placeholder}
value={sort}
/>
@ -210,7 +218,7 @@ const RicercaSideFilters = () => {
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">
<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>
);
@ -240,7 +248,7 @@ const DrowdownFilters = () => {
</DropdownMenuTrigger>
<DropdownMenuContent
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 />
</DropdownMenuContent>
@ -253,7 +261,7 @@ const RicercaHeader = () => {
const { map, setAllParams } = useRicerca();
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 items-center gap-2 sm:w-auto">
<DrowdownFilters />
@ -348,7 +356,7 @@ const AnnunciList = () => {
interface SelectFilterProps<T> {
label: string;
id: string;
options: string[];
options: FilterOption<string>[];
defaultValue?: T;
value: T | undefined;
onValueChange: (value: T) => void;
@ -402,8 +410,13 @@ export const SelectFilter = <T extends string>({
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem className="text-left" key={option} value={option}>
{option}
<SelectItem
className="text-left"
disabled={option.disabled}
key={option.value}
value={option.value}
>
{option.value}
</SelectItem>
))}
</SelectContent>
@ -420,7 +433,13 @@ const ConsegnaFilter = () => {
if (consegnaOptions.length === 0) return [];
return consegnaOptions
.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);
}, [consegnaOptions, t.parametri.mesi]);
@ -467,11 +486,12 @@ const ConsegnaFilter = () => {
</div>
<div className="flex flex-wrap gap-1 rounded-md bg-background p-1">
{mappedConsegnaOptions.map(({ value, label }) => (
{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);
@ -479,14 +499,14 @@ const ConsegnaFilter = () => {
size="sm"
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>
))}
</div>
</div>
);
};
const MAX_SLIDER_VALUE = 1500;
const BudgetFilters = () => {
const { t } = useTranslation();
const { minBudget, maxBudget, setAllParams } = useRicerca();
@ -509,80 +529,39 @@ const BudgetFilters = () => {
[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 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">
<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",
minBudget !== null && "text-primary",
)}
htmlFor="prezzoMIN"
className={cn("font-semibold text-muted-foreground")}
htmlFor="slider"
>
{t.annunci.budgetMin}
{t.annunci.budget}
</Label>
{minBudget !== null && (
{(minBudget !== null || maxBudget !== 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,
});
}}
@ -592,24 +571,22 @@ const BudgetFilters = () => {
</button>
)}
</div>
<NumberInput
className="bg-background"
decimalScale={2}
decimalSeparator=","
fixedDecimalScale
id="prezzoMAX"
<Slider
className="mt-2"
id="slider"
max={MAX_SLIDER_VALUE}
min={0}
onValueChange={(v) => {
if (v && v > 0) {
maxDebounced(v * 100);
} else {
maxDebounced(null);
}
}}
stepper={100}
suffix=" €"
value={Number(maxBudget) / 100}
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>
</>
);

View file

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