@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.
@@ -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
+ }