@turtleclub/core 0.1.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1179 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +574 -0
- package/dist/index.d.ts +574 -0
- package/dist/index.js +1150 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
- package/src/filters/BooleanFilter.tsx +46 -0
- package/src/filters/Filter.tsx +44 -0
- package/src/filters/MultiSelectFilter.tsx +46 -0
- package/src/filters/RangeSliderFilter.tsx +250 -0
- package/src/filters/index.ts +4 -0
- package/src/helpers/index.ts +5 -0
- package/src/helpers/usePagination.ts +210 -0
- package/src/helpers/useSorting.ts +185 -0
- package/src/index.ts +4 -0
- package/src/selectors/ChainSelector.tsx +72 -0
- package/src/selectors/ChainsSelector.tsx +76 -0
- package/src/selectors/OpportunitiesSelector.tsx +79 -0
- package/src/selectors/OpportunitySelector.tsx +75 -0
- package/src/selectors/ProductSelector.tsx +69 -0
- package/src/selectors/ProductsSelector.tsx +73 -0
- package/src/selectors/TokenSelector.tsx +85 -0
- package/src/selectors/TokensSelector.tsx +89 -0
- package/src/selectors/index.ts +11 -0
- package/src/wrappers/ActiveFilterBadges.tsx +139 -0
- package/src/wrappers/FiltersGrid.tsx +69 -0
- package/src/wrappers/FiltersPopover.tsx +113 -0
- package/src/wrappers/FiltersWrapper.tsx +159 -0
- package/src/wrappers/README.md +272 -0
- package/src/wrappers/index.ts +8 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { Combobox } from "@turtleclub/ui";
|
|
5
|
+
import { useProducts } from "@turtleclub/hooks";
|
|
6
|
+
|
|
7
|
+
interface ProductSelectorProps {
|
|
8
|
+
/** Selected product ID */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Callback when selection changes */
|
|
11
|
+
onValueChange: (value: string) => void;
|
|
12
|
+
/** Placeholder text */
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
/** Whether the selector is disabled */
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
/** Custom className */
|
|
17
|
+
className?: string;
|
|
18
|
+
/** Whether to close on select */
|
|
19
|
+
closeOnSelect?: boolean;
|
|
20
|
+
/** Whether to show search */
|
|
21
|
+
searchable?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ProductSelector({
|
|
25
|
+
value,
|
|
26
|
+
onValueChange,
|
|
27
|
+
placeholder = "Select product",
|
|
28
|
+
disabled = false,
|
|
29
|
+
className,
|
|
30
|
+
closeOnSelect = true,
|
|
31
|
+
searchable = true,
|
|
32
|
+
}: ProductSelectorProps) {
|
|
33
|
+
const { data: productsData, isLoading } = useProducts({});
|
|
34
|
+
|
|
35
|
+
const products = useMemo(() => productsData?.products ?? [], [productsData?.products]);
|
|
36
|
+
|
|
37
|
+
const productOptions = useMemo(() => {
|
|
38
|
+
if (!products || products.length === 0) return [];
|
|
39
|
+
|
|
40
|
+
return products.map((product) => ({
|
|
41
|
+
label: product.name,
|
|
42
|
+
value: product.id,
|
|
43
|
+
icon: product.logoUrl
|
|
44
|
+
? () => (
|
|
45
|
+
<img
|
|
46
|
+
src={product.logoUrl}
|
|
47
|
+
alt={product.name}
|
|
48
|
+
width={16}
|
|
49
|
+
height={16}
|
|
50
|
+
className="size-4 rounded-full"
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
: undefined,
|
|
54
|
+
}));
|
|
55
|
+
}, [products]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Combobox
|
|
59
|
+
searchable={searchable}
|
|
60
|
+
options={productOptions}
|
|
61
|
+
value={value}
|
|
62
|
+
onValueChange={onValueChange}
|
|
63
|
+
disabled={disabled || isLoading}
|
|
64
|
+
placeholder={isLoading ? "Loading products..." : placeholder}
|
|
65
|
+
closeOnSelect={closeOnSelect}
|
|
66
|
+
className={className}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { MultiSelect } from "@turtleclub/ui";
|
|
5
|
+
import { useProducts } from "@turtleclub/hooks";
|
|
6
|
+
|
|
7
|
+
interface ProductSelectorProps {
|
|
8
|
+
/** Selected product IDs */
|
|
9
|
+
value: string[];
|
|
10
|
+
/** Callback when selection changes */
|
|
11
|
+
onValueChange: (values: string[]) => void;
|
|
12
|
+
/** Placeholder text */
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
/** Whether the selector is disabled */
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
/** Custom className */
|
|
17
|
+
className?: string;
|
|
18
|
+
/** Maximum count to show in trigger */
|
|
19
|
+
maxCount?: number;
|
|
20
|
+
/** Whether to close on select */
|
|
21
|
+
closeOnSelect?: boolean;
|
|
22
|
+
/** Whether to show search */
|
|
23
|
+
searchable?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ProductsSelector({
|
|
27
|
+
value,
|
|
28
|
+
onValueChange,
|
|
29
|
+
placeholder = "Select products",
|
|
30
|
+
disabled = false,
|
|
31
|
+
className,
|
|
32
|
+
maxCount = 1,
|
|
33
|
+
closeOnSelect = true,
|
|
34
|
+
searchable = true,
|
|
35
|
+
}: ProductSelectorProps) {
|
|
36
|
+
const { data: productsData, isLoading } = useProducts({});
|
|
37
|
+
|
|
38
|
+
const products = useMemo(() => productsData?.products ?? [], [productsData?.products]);
|
|
39
|
+
|
|
40
|
+
const productOptions = useMemo(() => {
|
|
41
|
+
if (!products || products.length === 0) return [];
|
|
42
|
+
|
|
43
|
+
return products.map((product) => ({
|
|
44
|
+
label: product.name,
|
|
45
|
+
value: product.id,
|
|
46
|
+
icon: product.logoUrl
|
|
47
|
+
? () => (
|
|
48
|
+
<img
|
|
49
|
+
src={product.logoUrl}
|
|
50
|
+
alt={product.name}
|
|
51
|
+
width={16}
|
|
52
|
+
height={16}
|
|
53
|
+
className="size-4 rounded-full"
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
: undefined,
|
|
57
|
+
}));
|
|
58
|
+
}, [products]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<MultiSelect
|
|
62
|
+
searchable={searchable}
|
|
63
|
+
options={productOptions}
|
|
64
|
+
value={value}
|
|
65
|
+
onValueChange={onValueChange}
|
|
66
|
+
disabled={disabled || isLoading}
|
|
67
|
+
placeholder={isLoading ? "Loading products..." : placeholder}
|
|
68
|
+
closeOnSelect={closeOnSelect}
|
|
69
|
+
maxCount={maxCount}
|
|
70
|
+
className={className}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo } from "react";
|
|
4
|
+
import { Combobox } from "@turtleclub/ui";
|
|
5
|
+
import { useSupportedTokens } from "@turtleclub/hooks";
|
|
6
|
+
|
|
7
|
+
interface TokenSelectorProps {
|
|
8
|
+
/** Selected token ID */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Callback when selection changes */
|
|
11
|
+
onValueChange: (value: string) => void;
|
|
12
|
+
/** Selected chain ID to filter tokens */
|
|
13
|
+
chainId?: string;
|
|
14
|
+
/** Placeholder text */
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
/** Whether the selector is disabled */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Custom className */
|
|
19
|
+
className?: string;
|
|
20
|
+
/** Whether to close on select */
|
|
21
|
+
closeOnSelect?: boolean;
|
|
22
|
+
/** Whether to show search */
|
|
23
|
+
searchable?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function TokenSelector({
|
|
27
|
+
value,
|
|
28
|
+
onValueChange,
|
|
29
|
+
chainId,
|
|
30
|
+
placeholder = "Select token",
|
|
31
|
+
disabled = false,
|
|
32
|
+
className,
|
|
33
|
+
closeOnSelect = true,
|
|
34
|
+
searchable = true,
|
|
35
|
+
}: TokenSelectorProps) {
|
|
36
|
+
const isChainSelected = !!chainId && chainId.trim() !== "";
|
|
37
|
+
|
|
38
|
+
const { tokens, isLoading } = useSupportedTokens({
|
|
39
|
+
chainId: isChainSelected ? chainId : "",
|
|
40
|
+
limit: 9000,
|
|
41
|
+
enabled: isChainSelected,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const tokenOptions = useMemo(() => {
|
|
45
|
+
if (!tokens) return [];
|
|
46
|
+
|
|
47
|
+
return tokens.map((token) => ({
|
|
48
|
+
label: `${token.symbol} - ${token.name}`,
|
|
49
|
+
value: token?.id || "",
|
|
50
|
+
icon: token.logoUrl
|
|
51
|
+
? () => (
|
|
52
|
+
<img
|
|
53
|
+
src={token.logoUrl}
|
|
54
|
+
alt={token.name}
|
|
55
|
+
width={16}
|
|
56
|
+
height={16}
|
|
57
|
+
className="size-4 rounded-full"
|
|
58
|
+
/>
|
|
59
|
+
)
|
|
60
|
+
: undefined,
|
|
61
|
+
}));
|
|
62
|
+
}, [tokens]);
|
|
63
|
+
|
|
64
|
+
// Clear selection when chain changes
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!isChainSelected && value) {
|
|
67
|
+
onValueChange("");
|
|
68
|
+
}
|
|
69
|
+
}, [isChainSelected, value, onValueChange]);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Combobox
|
|
73
|
+
searchable={searchable}
|
|
74
|
+
options={tokenOptions}
|
|
75
|
+
value={value}
|
|
76
|
+
onValueChange={onValueChange}
|
|
77
|
+
disabled={disabled || isLoading || !isChainSelected}
|
|
78
|
+
placeholder={
|
|
79
|
+
!isChainSelected ? "Select a chain first" : isLoading ? "Loading tokens..." : placeholder
|
|
80
|
+
}
|
|
81
|
+
closeOnSelect={closeOnSelect}
|
|
82
|
+
className={className}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo } from "react";
|
|
4
|
+
import { MultiSelect } from "@turtleclub/ui";
|
|
5
|
+
import { useSupportedTokens } from "@turtleclub/hooks";
|
|
6
|
+
|
|
7
|
+
interface TokenSelectorProps {
|
|
8
|
+
/** Selected token IDs */
|
|
9
|
+
value: string[];
|
|
10
|
+
/** Callback when selection changes */
|
|
11
|
+
onValueChange: (values: string[]) => void;
|
|
12
|
+
/** Selected chain ID to filter tokens */
|
|
13
|
+
chainId?: string;
|
|
14
|
+
/** Placeholder text */
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
/** Whether the selector is disabled */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Custom className */
|
|
19
|
+
className?: string;
|
|
20
|
+
/** Maximum count to show in trigger */
|
|
21
|
+
maxCount?: number;
|
|
22
|
+
/** Whether to close on select */
|
|
23
|
+
closeOnSelect?: boolean;
|
|
24
|
+
/** Whether to show search */
|
|
25
|
+
searchable?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function TokensSelector({
|
|
29
|
+
value,
|
|
30
|
+
onValueChange,
|
|
31
|
+
chainId,
|
|
32
|
+
placeholder = "Select tokens",
|
|
33
|
+
disabled = false,
|
|
34
|
+
className,
|
|
35
|
+
maxCount = 1,
|
|
36
|
+
closeOnSelect = true,
|
|
37
|
+
searchable = true,
|
|
38
|
+
}: TokenSelectorProps) {
|
|
39
|
+
const isChainSelected = !!chainId && chainId.trim() !== "";
|
|
40
|
+
|
|
41
|
+
const { tokens, isLoading } = useSupportedTokens({
|
|
42
|
+
chainId: isChainSelected ? chainId : "",
|
|
43
|
+
limit: 9000,
|
|
44
|
+
enabled: isChainSelected,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const tokenOptions = useMemo(() => {
|
|
48
|
+
if (!tokens) return [];
|
|
49
|
+
|
|
50
|
+
return tokens.map((token) => ({
|
|
51
|
+
label: `${token.symbol} - ${token.name}`,
|
|
52
|
+
value: token?.id || "",
|
|
53
|
+
icon: token.logoUrl
|
|
54
|
+
? () => (
|
|
55
|
+
<img
|
|
56
|
+
src={token.logoUrl}
|
|
57
|
+
alt={token.name}
|
|
58
|
+
width={16}
|
|
59
|
+
height={16}
|
|
60
|
+
className="size-4 rounded-full"
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
: undefined,
|
|
64
|
+
}));
|
|
65
|
+
}, [tokens]);
|
|
66
|
+
|
|
67
|
+
// Clear selection when chain changes
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!isChainSelected && value.length > 0) {
|
|
70
|
+
onValueChange([]);
|
|
71
|
+
}
|
|
72
|
+
}, [isChainSelected, value, onValueChange]);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<MultiSelect
|
|
76
|
+
searchable={searchable}
|
|
77
|
+
options={tokenOptions}
|
|
78
|
+
value={value}
|
|
79
|
+
onValueChange={onValueChange}
|
|
80
|
+
disabled={disabled || isLoading || !isChainSelected}
|
|
81
|
+
placeholder={
|
|
82
|
+
!isChainSelected ? "Select a chain first" : isLoading ? "Loading tokens..." : placeholder
|
|
83
|
+
}
|
|
84
|
+
closeOnSelect={closeOnSelect}
|
|
85
|
+
maxCount={maxCount}
|
|
86
|
+
className={className}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Plural selectors (MultiSelect)
|
|
2
|
+
export { ChainsSelector } from "./ChainsSelector";
|
|
3
|
+
export { TokensSelector } from "./TokensSelector";
|
|
4
|
+
export { ProductsSelector } from "./ProductsSelector";
|
|
5
|
+
export { OpportunitiesSelector } from "./OpportunitiesSelector";
|
|
6
|
+
|
|
7
|
+
// Singular selectors (Combobox)
|
|
8
|
+
export { ChainSelector } from "./ChainSelector";
|
|
9
|
+
export { TokenSelector } from "./TokenSelector";
|
|
10
|
+
export { ProductSelector } from "./ProductSelector";
|
|
11
|
+
export { OpportunitySelector } from "./OpportunitySelector";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useMemo } from "react";
|
|
4
|
+
import { Badge, Button, cn } from "@turtleclub/ui";
|
|
5
|
+
import { X } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
export interface ActiveFilterConfig {
|
|
8
|
+
/** Unique key for the filter */
|
|
9
|
+
key: string;
|
|
10
|
+
/** Display label for the filter */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Current filter value to display */
|
|
13
|
+
value: string;
|
|
14
|
+
/** Query parameter key(s) this filter uses */
|
|
15
|
+
queryKeys: string | string[];
|
|
16
|
+
/** Whether this filter is currently active */
|
|
17
|
+
isActive: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ActiveFilterBadgesProps {
|
|
21
|
+
/** Array of filter configurations */
|
|
22
|
+
filters: ActiveFilterConfig[];
|
|
23
|
+
/** Custom className for the container */
|
|
24
|
+
className?: string;
|
|
25
|
+
/** Label for clear all button (default: "Clear All") */
|
|
26
|
+
clearAllLabel?: string;
|
|
27
|
+
/** Whether to show clear all button (default: true) */
|
|
28
|
+
showClearAll?: boolean;
|
|
29
|
+
/** Custom render function for individual badges */
|
|
30
|
+
renderBadge?: (filter: ActiveFilterConfig, clearFilter: () => void) => React.ReactNode;
|
|
31
|
+
/** Callback function to clear individual filter */
|
|
32
|
+
onClearFilter: (queryKeys: string | string[]) => void;
|
|
33
|
+
/** Callback function to clear all filters */
|
|
34
|
+
onClearAll: (allQueryKeys: (string | string[])[]) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Component that displays active filter badges with individual clear buttons
|
|
39
|
+
* and an optional "clear all" button.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```tsx
|
|
43
|
+
* const activeFilters = [
|
|
44
|
+
* {
|
|
45
|
+
* key: 'chain',
|
|
46
|
+
* label: 'Chain',
|
|
47
|
+
* value: 'Ethereum',
|
|
48
|
+
* queryKeys: 'chainId',
|
|
49
|
+
* isActive: true,
|
|
50
|
+
* },
|
|
51
|
+
* {
|
|
52
|
+
* key: 'sorting',
|
|
53
|
+
* label: 'Sort',
|
|
54
|
+
* value: 'Name (A-Z)',
|
|
55
|
+
* queryKeys: ['sortBy', 'sortOrder'],
|
|
56
|
+
* isActive: true,
|
|
57
|
+
* },
|
|
58
|
+
* ];
|
|
59
|
+
*
|
|
60
|
+
* <ActiveFilterBadges filters={activeFilters} />
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function ActiveFilterBadges({
|
|
64
|
+
filters,
|
|
65
|
+
className,
|
|
66
|
+
clearAllLabel = "Clear All",
|
|
67
|
+
showClearAll = true,
|
|
68
|
+
renderBadge,
|
|
69
|
+
onClearFilter,
|
|
70
|
+
onClearAll,
|
|
71
|
+
}: ActiveFilterBadgesProps) {
|
|
72
|
+
// Get only active filters
|
|
73
|
+
const activeFilters = useMemo(() => {
|
|
74
|
+
return filters.filter((filter) => filter.isActive && filter.value);
|
|
75
|
+
}, [filters]);
|
|
76
|
+
|
|
77
|
+
// Clear all function
|
|
78
|
+
const clearAll = () => {
|
|
79
|
+
const allQueryKeys = activeFilters.map((filter) => filter.queryKeys);
|
|
80
|
+
onClearAll(allQueryKeys);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Don't render if no active filters
|
|
84
|
+
if (activeFilters.length === 0) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className={cn("flex flex-wrap items-center gap-2", className)}>
|
|
90
|
+
{activeFilters.map((filter) => {
|
|
91
|
+
return (
|
|
92
|
+
<FilterBadge
|
|
93
|
+
key={filter.key}
|
|
94
|
+
filter={filter}
|
|
95
|
+
renderBadge={renderBadge}
|
|
96
|
+
onClearFilter={onClearFilter}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
|
|
101
|
+
{showClearAll && activeFilters.length > 1 && (
|
|
102
|
+
<Button variant="ghost" size="sm" onClick={clearAll} className="h-7 px-2 text-xs">
|
|
103
|
+
{clearAllLabel}
|
|
104
|
+
</Button>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface FilterBadgeProps {
|
|
111
|
+
filter: ActiveFilterConfig;
|
|
112
|
+
renderBadge?: (filter: ActiveFilterConfig, clearFilter: () => void) => React.ReactNode;
|
|
113
|
+
onClearFilter: (queryKeys: string | string[]) => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function FilterBadge({ filter, renderBadge, onClearFilter }: FilterBadgeProps) {
|
|
117
|
+
const clearFilter = () => {
|
|
118
|
+
onClearFilter(filter.queryKeys);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (renderBadge) {
|
|
122
|
+
return <>{renderBadge(filter, clearFilter)}</>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<Badge className="flex items-center gap-1 pr-1 border-border border">
|
|
127
|
+
<span className="text-xs">
|
|
128
|
+
{filter.label}: {filter.value}
|
|
129
|
+
</span>
|
|
130
|
+
<button
|
|
131
|
+
onClick={clearFilter}
|
|
132
|
+
className="ml-1 rounded-full p-0.5 hover:bg-black/10 focus:bg-black/10 focus:outline-none"
|
|
133
|
+
aria-label={`Clear ${filter.label} filter`}
|
|
134
|
+
>
|
|
135
|
+
<X className="h-3 w-3" />
|
|
136
|
+
</button>
|
|
137
|
+
</Badge>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { cn, Label } from "@turtleclub/ui";
|
|
5
|
+
|
|
6
|
+
export interface FilterConfig {
|
|
7
|
+
key: string;
|
|
8
|
+
label: string;
|
|
9
|
+
component: React.ReactNode;
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
section?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FiltersGridProps {
|
|
15
|
+
filters: FilterConfig[];
|
|
16
|
+
columns?: number;
|
|
17
|
+
className?: string;
|
|
18
|
+
sectionClassName?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function FiltersGrid({
|
|
22
|
+
filters,
|
|
23
|
+
columns = 3,
|
|
24
|
+
className,
|
|
25
|
+
sectionClassName,
|
|
26
|
+
}: FiltersGridProps) {
|
|
27
|
+
const enabledFilters = filters.filter((filter) => filter.enabled);
|
|
28
|
+
|
|
29
|
+
if (enabledFilters.length === 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Group filters by section
|
|
34
|
+
const sections = enabledFilters.reduce(
|
|
35
|
+
(acc, filter) => {
|
|
36
|
+
const sectionName = filter.section || "default";
|
|
37
|
+
if (!acc[sectionName]) {
|
|
38
|
+
acc[sectionName] = [];
|
|
39
|
+
}
|
|
40
|
+
acc[sectionName].push(filter);
|
|
41
|
+
return acc;
|
|
42
|
+
},
|
|
43
|
+
{} as Record<string, typeof enabledFilters>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const sectionNames = Object.keys(sections);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className={cn("space-y-4", className)}>
|
|
50
|
+
{sectionNames.map((sectionName, sectionIndex) => (
|
|
51
|
+
<div key={sectionName}>
|
|
52
|
+
{sectionIndex > 0 && <div className="border-t border-border mb-4" />}
|
|
53
|
+
<div
|
|
54
|
+
className={cn("grid gap-2", sectionClassName)}
|
|
55
|
+
style={{
|
|
56
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{sections[sectionName].map((filter) => (
|
|
60
|
+
<div key={filter.key} className="flex flex-col gap-2">
|
|
61
|
+
{filter.component}
|
|
62
|
+
</div>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import {
|
|
5
|
+
Popover,
|
|
6
|
+
PopoverContent,
|
|
7
|
+
PopoverTrigger,
|
|
8
|
+
buttonVariants,
|
|
9
|
+
cn,
|
|
10
|
+
Label,
|
|
11
|
+
Badge,
|
|
12
|
+
} from "@turtleclub/ui";
|
|
13
|
+
import { SlidersHorizontal } from "lucide-react";
|
|
14
|
+
import { FilterConfig } from "./FiltersGrid";
|
|
15
|
+
|
|
16
|
+
export interface FiltersPopoverProps {
|
|
17
|
+
filters: FilterConfig[];
|
|
18
|
+
triggerLabel?: React.ReactNode;
|
|
19
|
+
columns?: number;
|
|
20
|
+
className?: string;
|
|
21
|
+
popoverClassName?: string;
|
|
22
|
+
sectionClassName?: string;
|
|
23
|
+
align?: "start" | "center" | "end";
|
|
24
|
+
/** Callback function to clear all filters */
|
|
25
|
+
onClearAll?: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function FiltersPopover({
|
|
29
|
+
filters,
|
|
30
|
+
triggerLabel = "Filters",
|
|
31
|
+
columns = 2,
|
|
32
|
+
className,
|
|
33
|
+
popoverClassName,
|
|
34
|
+
sectionClassName,
|
|
35
|
+
align = "start",
|
|
36
|
+
onClearAll,
|
|
37
|
+
}: FiltersPopoverProps) {
|
|
38
|
+
const enabledFilters = filters.filter((filter) => filter.enabled);
|
|
39
|
+
|
|
40
|
+
if (enabledFilters.length === 0) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Group filters by section
|
|
45
|
+
const sections = enabledFilters.reduce(
|
|
46
|
+
(acc, filter) => {
|
|
47
|
+
const sectionName = filter.section || "default";
|
|
48
|
+
if (!acc[sectionName]) {
|
|
49
|
+
acc[sectionName] = [];
|
|
50
|
+
}
|
|
51
|
+
acc[sectionName].push(filter);
|
|
52
|
+
return acc;
|
|
53
|
+
},
|
|
54
|
+
{} as Record<string, typeof enabledFilters>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const sectionNames = Object.keys(sections);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Popover>
|
|
61
|
+
<PopoverTrigger
|
|
62
|
+
className={cn(
|
|
63
|
+
buttonVariants({
|
|
64
|
+
variant: "default",
|
|
65
|
+
size: "default",
|
|
66
|
+
border: "bordered",
|
|
67
|
+
}),
|
|
68
|
+
"!bg-neutral-alpha-2",
|
|
69
|
+
className
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{triggerLabel}
|
|
73
|
+
</PopoverTrigger>
|
|
74
|
+
<PopoverContent
|
|
75
|
+
className={cn("w-auto min-w-[400px] max-w-[600px]", popoverClassName)}
|
|
76
|
+
align={align}
|
|
77
|
+
>
|
|
78
|
+
<div className="space-y-4">
|
|
79
|
+
<div className="flex items-center justify-between">
|
|
80
|
+
<h4 className="font-medium text-sm">Filters</h4>
|
|
81
|
+
{onClearAll && (
|
|
82
|
+
<button
|
|
83
|
+
onClick={onClearAll}
|
|
84
|
+
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
85
|
+
>
|
|
86
|
+
Reset all
|
|
87
|
+
</button>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
<div className="space-y-4">
|
|
91
|
+
{sectionNames.map((sectionName, sectionIndex) => (
|
|
92
|
+
<div key={sectionName}>
|
|
93
|
+
{sectionIndex > 0 && <div className="border-t border-border mb-4" />}
|
|
94
|
+
<div
|
|
95
|
+
className={cn("grid gap-2", sectionClassName)}
|
|
96
|
+
style={{
|
|
97
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{sections[sectionName].map((filter) => (
|
|
101
|
+
<div key={filter.key} className="flex flex-col gap-2">
|
|
102
|
+
{filter.component}
|
|
103
|
+
</div>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</PopoverContent>
|
|
111
|
+
</Popover>
|
|
112
|
+
);
|
|
113
|
+
}
|