@kasuvia/sdk 0.0.8 → 0.0.9

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.
Files changed (176) hide show
  1. package/README.md +42 -15
  2. package/dist/AddressAutocomplete-CjcqPElD.d.mts +46 -0
  3. package/dist/AddressAutocomplete-CvJHuRkf.d.ts +46 -0
  4. package/dist/components/index.d.mts +497 -0
  5. package/dist/components/index.d.ts +497 -0
  6. package/dist/components/index.js +4217 -0
  7. package/dist/components/index.js.map +1 -0
  8. package/dist/components/index.mjs +4194 -0
  9. package/dist/components/index.mjs.map +1 -0
  10. package/dist/config/server.d.mts +26 -0
  11. package/dist/config/server.d.ts +26 -0
  12. package/dist/config/server.js +38 -0
  13. package/dist/config/server.js.map +1 -0
  14. package/dist/config/server.mjs +13 -0
  15. package/dist/config/server.mjs.map +1 -0
  16. package/dist/hooks/index.d.mts +393 -160
  17. package/dist/hooks/index.d.ts +393 -160
  18. package/dist/hooks/index.js +2792 -2137
  19. package/dist/hooks/index.js.map +1 -1
  20. package/dist/hooks/index.mjs +2734 -2121
  21. package/dist/hooks/index.mjs.map +1 -1
  22. package/dist/index-CNW_LINy.d.mts +578 -0
  23. package/dist/index-CNW_LINy.d.ts +578 -0
  24. package/dist/{index-C1chUA3P.d.mts → index-F7N6UItS.d.mts} +1 -1
  25. package/dist/{index-C1chUA3P.d.ts → index-F7N6UItS.d.ts} +1 -1
  26. package/dist/index-NkI10J_E.d.mts +40 -0
  27. package/dist/index-NkI10J_E.d.ts +40 -0
  28. package/dist/index.d.mts +20437 -103
  29. package/dist/index.d.ts +20437 -103
  30. package/dist/index.js +2111 -1802
  31. package/dist/index.js.map +1 -1
  32. package/dist/index.mjs +2006 -1786
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/media.service-Cx7dQjzX.d.mts +1393 -0
  35. package/dist/media.service-mrBFJ2UQ.d.ts +1393 -0
  36. package/dist/services/index.d.mts +908 -2
  37. package/dist/services/index.d.ts +908 -2
  38. package/dist/services/index.js +1492 -1686
  39. package/dist/services/index.js.map +1 -1
  40. package/dist/services/index.mjs +1431 -1670
  41. package/dist/services/index.mjs.map +1 -1
  42. package/package.json +46 -6
  43. package/src/components/bespoke/BespokeAvailabilityBadge.tsx +32 -0
  44. package/src/components/bespoke/BespokeProgressTracker.tsx +58 -0
  45. package/src/components/bespoke/index.ts +3 -0
  46. package/src/components/booking/SlotPicker.tsx +97 -0
  47. package/src/components/booking/index.ts +1 -0
  48. package/src/components/cart/CartItem.tsx +81 -0
  49. package/src/components/cart/CartSidebar.tsx +115 -0
  50. package/src/components/cart/CartTypeMismatchBanner.tsx +55 -0
  51. package/src/components/cart/index.ts +3 -0
  52. package/src/components/index.ts +8 -0
  53. package/src/components/orders/DeliverableReviewer.tsx +139 -0
  54. package/src/components/orders/index.ts +1 -0
  55. package/src/components/product/ProductCard.tsx +350 -0
  56. package/src/components/product/ProductGrid.tsx +84 -0
  57. package/src/components/product/index.ts +2 -0
  58. package/src/components/shared/AddressAutocomplete.tsx +212 -0
  59. package/src/components/shared/AnalyticsScripts.tsx +56 -0
  60. package/src/components/shared/CookieConsentBanner.tsx +98 -0
  61. package/src/components/shared/ImageEditorModal.tsx +1066 -0
  62. package/src/components/shared/ImageViewerModal.tsx +598 -0
  63. package/src/components/shared/LucideReactIcon.tsx +52 -0
  64. package/src/components/shared/MapPinDrop.tsx +101 -0
  65. package/src/components/shared/MultiSelectPicker.tsx +193 -0
  66. package/src/components/shared/OfflineBanner.tsx +46 -0
  67. package/src/components/shared/SearchableInput.tsx +153 -0
  68. package/src/components/shared/SearchablePopover.tsx +166 -0
  69. package/src/components/shared/SmartImage.tsx +311 -0
  70. package/src/components/shared/TruncatedValue.tsx +27 -0
  71. package/src/components/shared/UnsavedChangesGuard.tsx +138 -0
  72. package/src/components/shared/index.ts +15 -0
  73. package/src/components/skeletons/CardSkeleton.tsx +26 -0
  74. package/src/components/skeletons/CartItemSkeleton.tsx +26 -0
  75. package/src/components/skeletons/OrderItemSkeleton.tsx +26 -0
  76. package/src/components/skeletons/ProductCardSkeleton.tsx +26 -0
  77. package/src/components/skeletons/TableSkeleton.tsx +42 -0
  78. package/src/components/skeletons/index.ts +5 -0
  79. package/src/config/index.ts +22 -4
  80. package/src/config/server.ts +45 -0
  81. package/src/context/sdk-context.tsx +5 -2
  82. package/src/generated/auth-openapi.ts +2935 -0
  83. package/src/generated/cart-openapi.ts +652 -0
  84. package/src/generated/cms-openapi.ts +1668 -0
  85. package/src/generated/crm-openapi.ts +986 -0
  86. package/src/generated/graphql.ts +126 -0
  87. package/src/generated/index.ts +11 -0
  88. package/src/generated/media-openapi.ts +613 -0
  89. package/src/generated/notification-openapi.ts +862 -0
  90. package/src/generated/payment-openapi.ts +2713 -0
  91. package/src/generated/store-openapi.ts +9510 -0
  92. package/src/graphql/operations/collections.graphql +32 -0
  93. package/src/graphql/operations/products.graphql +197 -0
  94. package/src/graphql/operations/wishlist.graphql +25 -0
  95. package/src/hooks/index.ts +9 -2
  96. package/src/hooks/use-auth.ts +163 -69
  97. package/src/hooks/use-bespoke-availability.ts +39 -22
  98. package/src/hooks/use-business-config.ts +51 -0
  99. package/src/hooks/use-cart.ts +310 -238
  100. package/src/hooks/use-cms.ts +107 -127
  101. package/src/hooks/use-collections.ts +35 -55
  102. package/src/hooks/use-consultations.ts +9 -136
  103. package/src/hooks/use-countries.ts +19 -28
  104. package/src/hooks/use-currencies.ts +8 -20
  105. package/src/hooks/use-customer-address.ts +64 -0
  106. package/src/hooks/use-feedback.ts +34 -0
  107. package/src/hooks/use-languages.ts +26 -39
  108. package/src/hooks/use-media-upload.ts +23 -0
  109. package/src/hooks/use-notification-stream.ts +88 -0
  110. package/src/hooks/use-notifications.ts +163 -0
  111. package/src/hooks/use-order-stream.ts +29 -29
  112. package/src/hooks/use-orders.ts +91 -73
  113. package/src/hooks/use-payment.ts +116 -84
  114. package/src/hooks/use-products.ts +65 -269
  115. package/src/hooks/use-push-notifications.ts +122 -0
  116. package/src/hooks/use-reviews.ts +175 -0
  117. package/src/hooks/use-service-booking.ts +109 -132
  118. package/src/hooks/use-states.ts +16 -30
  119. package/src/hooks/use-wishlist.ts +44 -69
  120. package/src/index.ts +15 -11
  121. package/src/services/auth.service.ts +422 -132
  122. package/src/services/bespoke.service.ts +69 -10
  123. package/src/services/cart.service.ts +155 -186
  124. package/src/services/cms.service.ts +41 -50
  125. package/src/services/collection.service.ts +25 -63
  126. package/src/services/config.service.ts +10 -21
  127. package/src/services/consultation.service.ts +36 -93
  128. package/src/services/feedback.service.ts +76 -0
  129. package/src/services/index.ts +7 -1
  130. package/src/services/legal.service.ts +62 -0
  131. package/src/services/logistics.service.ts +235 -53
  132. package/src/services/media.service.ts +115 -0
  133. package/src/services/notifications.service.ts +204 -20
  134. package/src/services/order.service.ts +498 -127
  135. package/src/services/payment.service.ts +149 -100
  136. package/src/services/product.service.ts +157 -814
  137. package/src/services/review.service.ts +211 -0
  138. package/src/services/service-booking.service.ts +198 -99
  139. package/src/services/wishlist.service.ts +27 -74
  140. package/src/types/auth/index.ts +161 -18
  141. package/src/types/business/index.ts +9 -1
  142. package/src/types/cms/index.ts +91 -6
  143. package/src/types/cms/legal.ts +18 -0
  144. package/src/types/consultations/index.ts +60 -68
  145. package/src/types/index.ts +5 -3
  146. package/src/types/media/index.ts +35 -3
  147. package/src/types/payments/index.ts +80 -16
  148. package/src/types/reviews/index.ts +116 -0
  149. package/src/types/services/index.ts +113 -38
  150. package/src/types/store/checkout.ts +53 -0
  151. package/src/types/store/index.ts +273 -52
  152. package/src/types/store/invoice.ts +31 -0
  153. package/src/types/store/shipping.ts +84 -3
  154. package/src/types/ui/index.ts +1 -1
  155. package/src/utils/checkout.ts +19 -3
  156. package/src/utils/cookieConsent.ts +34 -0
  157. package/src/utils/format.ts +408 -0
  158. package/src/utils/geolocation.ts +134 -0
  159. package/src/utils/graphql-url.ts +47 -0
  160. package/src/utils/guest-session.ts +59 -0
  161. package/src/utils/http-client.ts +147 -0
  162. package/src/utils/index.ts +11 -4
  163. package/src/utils/status-colors.ts +79 -0
  164. package/src/utils/token-refresh.ts +115 -0
  165. package/src/utils/validators.ts +13 -0
  166. package/src/utils/youtube.ts +18 -0
  167. package/dist/index-C5YO9gTl.d.mts +0 -507
  168. package/dist/index-C5YO9gTl.d.ts +0 -507
  169. package/dist/index-CefG7_xq.d.mts +0 -793
  170. package/dist/index-DeHCf3zI.d.ts +0 -793
  171. package/src/graphql/collections.graphql.ts +0 -48
  172. package/src/graphql/index.ts +0 -7
  173. package/src/graphql/products.graphql.ts +0 -860
  174. package/src/graphql/wishlist.graphql.ts +0 -34
  175. package/src/hooks/use-consultation-stream.ts +0 -142
  176. package/src/types/bespoke/index.ts +0 -39
@@ -0,0 +1,101 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Loader2 } from "lucide-react";
5
+
6
+ export interface MapPinDropProps {
7
+ initialLatitude?: number;
8
+ initialLongitude?: number;
9
+ onPinDrop?: (coordinates: { latitude: number; longitude: number }) => void;
10
+ value?: { latitude: number; longitude: number } | null;
11
+ height?: string;
12
+ disabled?: boolean;
13
+ zoom?: number;
14
+ }
15
+
16
+ export function MapPinDrop({
17
+ initialLatitude = 6.5244,
18
+ initialLongitude = 3.3792,
19
+ onPinDrop,
20
+ value,
21
+ height = "300px",
22
+ disabled = false,
23
+ }: MapPinDropProps) {
24
+ const [isMounted, setIsMounted] = useState(false);
25
+ const [lat, setLat] = useState<number>(value?.latitude ?? initialLatitude);
26
+ const [lng, setLng] = useState<number>(value?.longitude ?? initialLongitude);
27
+
28
+ useEffect(() => {
29
+ setIsMounted(true);
30
+ }, []);
31
+
32
+ useEffect(() => {
33
+ if (value) {
34
+ setLat(value.latitude);
35
+ setLng(value.longitude);
36
+ }
37
+ }, [value]);
38
+
39
+ if (!isMounted) {
40
+ return (
41
+ <div
42
+ className="w-full rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 flex items-center justify-center"
43
+ style={{ height }}
44
+ >
45
+ <Loader2 className="w-6 h-6 text-gray-400 animate-spin" />
46
+ </div>
47
+ );
48
+ }
49
+
50
+ return (
51
+ <div className="w-full rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
52
+ <div className="p-4 flex flex-col gap-3">
53
+ <div className="flex items-center justify-between text-xs text-gray-500">
54
+ <span>Coordinates Selector</span>
55
+ <span>
56
+ {lat.toFixed(6)}, {lng.toFixed(6)}
57
+ </span>
58
+ </div>
59
+ {!disabled && (
60
+ <div className="grid grid-cols-2 gap-2">
61
+ <div>
62
+ <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
63
+ Latitude
64
+ </label>
65
+ <input
66
+ type="number"
67
+ step="any"
68
+ value={lat}
69
+ onChange={(e) => {
70
+ const val = parseFloat(e.target.value) || 0;
71
+ setLat(val);
72
+ onPinDrop?.({ latitude: val, longitude: lng });
73
+ }}
74
+ className="w-full h-9 px-2 text-xs border border-gray-300 dark:border-gray-700 rounded bg-transparent"
75
+ />
76
+ </div>
77
+ <div>
78
+ <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
79
+ Longitude
80
+ </label>
81
+ <input
82
+ type="number"
83
+ step="any"
84
+ value={lng}
85
+ onChange={(e) => {
86
+ const val = parseFloat(e.target.value) || 0;
87
+ setLng(val);
88
+ onPinDrop?.({ latitude: lat, longitude: val });
89
+ }}
90
+ className="w-full h-9 px-2 text-xs border border-gray-300 dark:border-gray-700 rounded bg-transparent"
91
+ />
92
+ </div>
93
+ </div>
94
+ )}
95
+ </div>
96
+ <div className="px-3 py-2 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500">
97
+ {disabled ? "Location view only" : "Adjust coordinates to fine-tune map location"}
98
+ </div>
99
+ </div>
100
+ );
101
+ }
@@ -0,0 +1,193 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect } from "react";
4
+ import { X, ChevronDown, Plus, Hash } from "lucide-react";
5
+
6
+ export interface SelectOption {
7
+ id: string;
8
+ name: string;
9
+ image_url?: string | null;
10
+ collection_type?: string;
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ export interface MultiSelectPickerProps {
15
+ selected: string[];
16
+ onChange: (items: string[]) => void;
17
+ options?: SelectOption[];
18
+ isLoading?: boolean;
19
+ isFetchingNextPage?: boolean;
20
+ hasNextPage?: boolean;
21
+ onFetchNextPage?: () => void;
22
+ placeholder?: string;
23
+ onCreateOption?: (name: string) => void;
24
+ onSearchChange?: (search: string) => void;
25
+ searchValue?: string;
26
+ }
27
+
28
+ export function MultiSelectPicker({
29
+ selected,
30
+ onChange,
31
+ options = [],
32
+ isLoading = false,
33
+ isFetchingNextPage = false,
34
+ hasNextPage = false,
35
+ onFetchNextPage,
36
+ placeholder = "Search or type to add...",
37
+ onCreateOption,
38
+ onSearchChange,
39
+ searchValue,
40
+ }: MultiSelectPickerProps) {
41
+ const [open, setOpen] = useState(false);
42
+ const serverSearch = typeof onSearchChange === "function";
43
+ const [localSearch, setLocalSearch] = useState("");
44
+ const search = serverSearch ? (searchValue ?? "") : localSearch;
45
+ const setSearch = serverSearch ? onSearchChange! : setLocalSearch;
46
+ const inputRef = useRef<HTMLInputElement>(null);
47
+ const containerRef = useRef<HTMLDivElement>(null);
48
+
49
+ useEffect(() => {
50
+ const handler = (e: MouseEvent) => {
51
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
52
+ setOpen(false);
53
+ setSearch("");
54
+ }
55
+ };
56
+ document.addEventListener("mousedown", handler);
57
+ return () => document.removeEventListener("mousedown", handler);
58
+ }, [setSearch]);
59
+
60
+ const filtered = options.filter(
61
+ (opt) =>
62
+ (serverSearch || opt.name.toLowerCase().includes(search.toLowerCase())) &&
63
+ !selected.includes(opt.name)
64
+ );
65
+
66
+ const showCustomOption =
67
+ search.trim().length > 0 &&
68
+ !options.some((opt) => opt.name.toLowerCase() === search.trim().toLowerCase()) &&
69
+ !selected.some((s) => s.toLowerCase() === search.trim().toLowerCase());
70
+
71
+ const addItem = (name: string) => {
72
+ onChange([...selected, name]);
73
+ setSearch("");
74
+ inputRef.current?.focus();
75
+ };
76
+
77
+ const removeItem = (name: string) => {
78
+ onChange(selected.filter((s) => s !== name));
79
+ };
80
+
81
+ return (
82
+ <div ref={containerRef} className="relative w-full">
83
+ <div
84
+ onClick={() => {
85
+ setOpen(true);
86
+ inputRef.current?.focus();
87
+ }}
88
+ className={`relative flex flex-wrap items-center gap-1.5 w-full cursor-text min-h-[42px] rounded-lg px-3 py-1.5 border border-input bg-background text-foreground ${
89
+ open ? "ring-2 ring-ring border-ring" : ""
90
+ }`}
91
+ >
92
+ {selected.map((name) => (
93
+ <span
94
+ key={name}
95
+ className="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-semibold bg-muted text-foreground border"
96
+ >
97
+ <span className="truncate max-w-[140px]">{name}</span>
98
+ <button
99
+ type="button"
100
+ onClick={(e) => {
101
+ e.stopPropagation();
102
+ removeItem(name);
103
+ }}
104
+ className="rounded p-0.5 hover:bg-accent"
105
+ >
106
+ <X className="h-3 w-3" />
107
+ </button>
108
+ </span>
109
+ ))}
110
+
111
+ <input
112
+ ref={inputRef}
113
+ value={search}
114
+ onChange={(e) => {
115
+ setSearch(e.target.value);
116
+ setOpen(true);
117
+ }}
118
+ onFocus={() => setOpen(true)}
119
+ placeholder={selected.length === 0 ? placeholder : ""}
120
+ className="flex-1 min-w-[120px] bg-transparent outline-none text-xs font-medium h-6 text-foreground placeholder:text-muted-foreground"
121
+ />
122
+
123
+ <ChevronDown
124
+ className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 ${
125
+ open ? "rotate-180" : ""
126
+ }`}
127
+ />
128
+ </div>
129
+
130
+ {open && (
131
+ <div className="absolute z-50 mt-1 w-full rounded-lg border bg-card text-foreground shadow-lg overflow-hidden p-1.5">
132
+ <div className="max-h-60 overflow-y-auto space-y-1">
133
+ {isLoading && options.length === 0 ? (
134
+ <div className="p-3 text-center text-xs text-muted-foreground">Loading options…</div>
135
+ ) : (
136
+ <>
137
+ {filtered.length === 0 && !showCustomOption && !isLoading && (
138
+ <div className="p-3 text-center text-xs text-muted-foreground">No options found</div>
139
+ )}
140
+
141
+ {filtered.map((opt) => (
142
+ <button
143
+ key={opt.id}
144
+ type="button"
145
+ onClick={() => addItem(opt.name)}
146
+ className="w-full text-left px-3 py-2 rounded-md flex items-center gap-2 hover:bg-muted transition-colors text-xs"
147
+ >
148
+ <div className="h-6 w-6 shrink-0 rounded bg-muted flex items-center justify-center">
149
+ <Hash className="h-3 w-3 text-muted-foreground" />
150
+ </div>
151
+ <div className="flex-1 min-w-0">
152
+ <div className="font-semibold truncate">{opt.name}</div>
153
+ </div>
154
+ <Plus className="h-3.5 w-3.5 text-primary shrink-0" />
155
+ </button>
156
+ ))}
157
+
158
+ {hasNextPage && (
159
+ <button
160
+ type="button"
161
+ onClick={() => onFetchNextPage?.()}
162
+ disabled={isFetchingNextPage}
163
+ className="w-full py-1.5 text-center text-xs text-primary font-semibold hover:underline"
164
+ >
165
+ {isFetchingNextPage ? "Loading more..." : "Load more..."}
166
+ </button>
167
+ )}
168
+
169
+ {showCustomOption && (
170
+ <button
171
+ type="button"
172
+ onClick={() => {
173
+ if (onCreateOption) {
174
+ onCreateOption(search.trim());
175
+ } else {
176
+ addItem(search.trim());
177
+ }
178
+ setSearch("");
179
+ }}
180
+ className="w-full text-left px-3 py-2 rounded-md flex items-center gap-2 bg-primary/10 text-primary font-bold text-xs hover:bg-primary/20"
181
+ >
182
+ <Plus className="h-3.5 w-3.5" />
183
+ <span>Create "{search.trim()}"</span>
184
+ </button>
185
+ )}
186
+ </>
187
+ )}
188
+ </div>
189
+ </div>
190
+ )}
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { WifiOff } from "lucide-react";
5
+
6
+ export interface OfflineBannerProps {
7
+ message?: string;
8
+ className?: string;
9
+ }
10
+
11
+ export function OfflineBanner({
12
+ message = "You are currently offline. Check your network connection.",
13
+ className = "",
14
+ }: OfflineBannerProps) {
15
+ const [isOffline, setIsOffline] = useState(false);
16
+
17
+ useEffect(() => {
18
+ if (typeof window === "undefined") return;
19
+
20
+ const handleOnline = () => setIsOffline(false);
21
+ const handleOffline = () => setIsOffline(true);
22
+
23
+ setIsOffline(!navigator.onLine);
24
+
25
+ window.addEventListener("online", handleOnline);
26
+ window.addEventListener("offline", handleOffline);
27
+
28
+ return () => {
29
+ window.removeEventListener("online", handleOnline);
30
+ window.removeEventListener("offline", handleOffline);
31
+ };
32
+ }, []);
33
+
34
+ if (!isOffline) return null;
35
+
36
+ return (
37
+ <div
38
+ className={`bg-amber-500/10 border-b border-amber-500/20 px-4 py-2 text-amber-700 dark:text-amber-400 text-xs font-medium flex items-center justify-between animate-in slide-in-from-top-2 duration-300 ${className}`.trim()}
39
+ >
40
+ <div className="flex items-center gap-2">
41
+ <WifiOff className="w-4 h-4 text-amber-500 shrink-0" />
42
+ <span>{message}</span>
43
+ </div>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect, ReactNode } from "react";
4
+ import { Check, X } from "lucide-react";
5
+
6
+ export interface SearchableInputItem {
7
+ value: string;
8
+ label: string;
9
+ description?: string;
10
+ icon?: ReactNode;
11
+ }
12
+
13
+ export interface SearchableInputProps {
14
+ items: SearchableInputItem[];
15
+ value: string;
16
+ onSelect: (value: string) => void;
17
+ placeholder?: string;
18
+ disabled?: boolean;
19
+ isLoading?: boolean;
20
+ skeletonCount?: number;
21
+ className?: string;
22
+ onSearchChange?: (search: string) => void;
23
+ debounceMs?: number;
24
+ }
25
+
26
+ export function SearchableInput({
27
+ items,
28
+ value,
29
+ onSelect,
30
+ placeholder = "Search...",
31
+ disabled = false,
32
+ isLoading = false,
33
+ className = "",
34
+ onSearchChange,
35
+ debounceMs = 300,
36
+ }: SearchableInputProps) {
37
+ const [isOpen, setIsOpen] = useState(false);
38
+ const [search, setSearch] = useState("");
39
+ const inputRef = useRef<HTMLInputElement>(null);
40
+ const dropdownRef = useRef<HTMLDivElement>(null);
41
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
42
+
43
+ const selectedItem = items.find((item) => item.value === value);
44
+
45
+ useEffect(() => {
46
+ if (selectedItem) {
47
+ setSearch(selectedItem.label);
48
+ }
49
+ }, [selectedItem]);
50
+
51
+ const handleSearchChange = (newValue: string) => {
52
+ setSearch(newValue);
53
+ if (debounceRef.current) clearTimeout(debounceRef.current);
54
+ debounceRef.current = setTimeout(() => {
55
+ onSearchChange?.(newValue);
56
+ }, debounceMs);
57
+ };
58
+
59
+ const handleSelect = (item: SearchableInputItem) => {
60
+ onSelect(item.value);
61
+ setSearch(item.label);
62
+ setIsOpen(false);
63
+ };
64
+
65
+ const handleClear = () => {
66
+ setSearch("");
67
+ onSelect("");
68
+ inputRef.current?.focus();
69
+ };
70
+
71
+ const serverSearch = typeof onSearchChange === "function";
72
+ const filteredItems = serverSearch
73
+ ? items
74
+ : items.filter(
75
+ (item) =>
76
+ item.label.toLowerCase().includes(search.toLowerCase()) ||
77
+ item.description?.toLowerCase().includes(search.toLowerCase())
78
+ );
79
+
80
+ useEffect(() => {
81
+ const handleClickOutside = (event: MouseEvent) => {
82
+ if (
83
+ dropdownRef.current &&
84
+ !dropdownRef.current.contains(event.target as Node) &&
85
+ inputRef.current &&
86
+ !inputRef.current.contains(event.target as Node)
87
+ ) {
88
+ setIsOpen(false);
89
+ }
90
+ };
91
+ document.addEventListener("mousedown", handleClickOutside);
92
+ return () => document.removeEventListener("mousedown", handleClickOutside);
93
+ }, []);
94
+
95
+ return (
96
+ <div className="relative w-full">
97
+ <div className="relative">
98
+ <input
99
+ ref={inputRef}
100
+ value={search}
101
+ onChange={(e) => handleSearchChange(e.target.value)}
102
+ onFocus={() => setIsOpen(true)}
103
+ placeholder={placeholder}
104
+ disabled={disabled}
105
+ className={`w-full h-10 px-3 pr-8 text-sm bg-background border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50 ${className}`.trim()}
106
+ />
107
+ {search && !disabled && (
108
+ <button
109
+ type="button"
110
+ onClick={handleClear}
111
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
112
+ >
113
+ <X className="w-4 h-4" />
114
+ </button>
115
+ )}
116
+ </div>
117
+
118
+ {isOpen && (
119
+ <div
120
+ ref={dropdownRef}
121
+ className="absolute z-50 w-full mt-1 bg-card border rounded-lg shadow-lg max-h-64 overflow-y-auto"
122
+ >
123
+ {isLoading ? (
124
+ <div className="p-3 text-center text-xs text-muted-foreground">Loading options…</div>
125
+ ) : filteredItems.length > 0 ? (
126
+ filteredItems.map((item) => (
127
+ <div
128
+ key={item.value}
129
+ onClick={() => handleSelect(item)}
130
+ className="flex items-center justify-between py-2 px-3 cursor-pointer hover:bg-muted transition-colors"
131
+ >
132
+ <div className="flex items-center gap-3 flex-1 min-w-0">
133
+ {item.icon}
134
+ <div className="flex-1 min-w-0">
135
+ <div className="font-medium text-sm text-foreground truncate">
136
+ {item.label}
137
+ </div>
138
+ {item.description && (
139
+ <div className="text-xs text-muted-foreground truncate">{item.description}</div>
140
+ )}
141
+ </div>
142
+ </div>
143
+ {value === item.value && <Check className="w-4 h-4 text-primary shrink-0" />}
144
+ </div>
145
+ ))
146
+ ) : (
147
+ <div className="p-4 text-center text-sm text-muted-foreground">No results found</div>
148
+ )}
149
+ </div>
150
+ )}
151
+ </div>
152
+ );
153
+ }
@@ -0,0 +1,166 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, ReactNode } from "react";
4
+ import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
5
+
6
+ export interface SearchableItem {
7
+ value: string;
8
+ label: string;
9
+ icon?: ReactNode;
10
+ description?: string;
11
+ }
12
+
13
+ export interface SearchablePopoverProps {
14
+ items: SearchableItem[];
15
+ value: string;
16
+ onSelect: (value: string) => void;
17
+ placeholder?: string;
18
+ searchPlaceholder?: string;
19
+ emptyMessage?: string;
20
+ disabled?: boolean;
21
+ isLoading?: boolean;
22
+ skeletonCount?: number;
23
+ className?: string;
24
+ triggerClassName?: string;
25
+ contentClassName?: string;
26
+ width?: string;
27
+ onSearchChange?: (search: string) => void;
28
+ searchValue?: string;
29
+ hasNextPage?: boolean;
30
+ isFetchingNextPage?: boolean;
31
+ onFetchNextPage?: () => void;
32
+ resolvedSelectedItem?: SearchableItem;
33
+ }
34
+
35
+ export function SearchablePopover({
36
+ items,
37
+ value,
38
+ onSelect,
39
+ placeholder = "Select an option",
40
+ searchPlaceholder = "Search...",
41
+ emptyMessage = "No results found.",
42
+ disabled = false,
43
+ isLoading = false,
44
+ triggerClassName = "",
45
+ contentClassName = "",
46
+ onSearchChange,
47
+ searchValue,
48
+ hasNextPage = false,
49
+ isFetchingNextPage = false,
50
+ onFetchNextPage,
51
+ resolvedSelectedItem,
52
+ }: SearchablePopoverProps) {
53
+ const [open, setOpen] = useState(false);
54
+ const [query, setQuery] = useState("");
55
+ const popoverRef = useRef<HTMLDivElement>(null);
56
+
57
+ const serverSearch = typeof onSearchChange === "function";
58
+ const activeSearch = serverSearch ? (searchValue ?? "") : query;
59
+
60
+ const selectedItem = resolvedSelectedItem ?? items.find((item) => item.value === value);
61
+
62
+ useEffect(() => {
63
+ const handleClickOutside = (e: MouseEvent) => {
64
+ if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
65
+ setOpen(false);
66
+ }
67
+ };
68
+ document.addEventListener("mousedown", handleClickOutside);
69
+ return () => document.removeEventListener("mousedown", handleClickOutside);
70
+ }, []);
71
+
72
+ const handleSearch = (text: string) => {
73
+ if (serverSearch) {
74
+ onSearchChange?.(text);
75
+ } else {
76
+ setQuery(text);
77
+ }
78
+ };
79
+
80
+ const filteredItems = serverSearch
81
+ ? items
82
+ : items.filter(
83
+ (item) =>
84
+ item.label.toLowerCase().includes(query.toLowerCase()) ||
85
+ item.description?.toLowerCase().includes(query.toLowerCase())
86
+ );
87
+
88
+ return (
89
+ <div ref={popoverRef} className="relative w-full">
90
+ <button
91
+ type="button"
92
+ disabled={disabled}
93
+ onClick={() => setOpen((prev) => !prev)}
94
+ className={`w-full h-10 px-3 py-2 text-sm bg-background border border-input rounded-lg flex items-center justify-between font-medium text-foreground disabled:opacity-50 ${triggerClassName}`.trim()}
95
+ >
96
+ {selectedItem ? (
97
+ <div className="flex items-center gap-2 truncate">
98
+ {selectedItem.icon}
99
+ <span>{selectedItem.label}</span>
100
+ </div>
101
+ ) : (
102
+ <span className="text-muted-foreground">{placeholder}</span>
103
+ )}
104
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
105
+ </button>
106
+
107
+ {open && (
108
+ <div
109
+ className={`absolute z-50 mt-1 w-full bg-card border rounded-lg shadow-xl overflow-hidden p-2 ${contentClassName}`.trim()}
110
+ >
111
+ <input
112
+ type="text"
113
+ value={activeSearch}
114
+ onChange={(e) => handleSearch(e.target.value)}
115
+ placeholder={searchPlaceholder}
116
+ className="w-full h-9 px-3 text-xs bg-muted border border-input rounded mb-2 focus:outline-none"
117
+ />
118
+
119
+ <div className="max-h-60 overflow-y-auto space-y-1">
120
+ {isLoading ? (
121
+ <div className="p-3 text-center text-xs text-muted-foreground flex items-center justify-center gap-2">
122
+ <Loader2 className="w-4 h-4 animate-spin" /> Loading options…
123
+ </div>
124
+ ) : filteredItems.length > 0 ? (
125
+ filteredItems.map((item) => (
126
+ <button
127
+ key={item.value}
128
+ type="button"
129
+ onClick={() => {
130
+ onSelect(item.value);
131
+ setOpen(false);
132
+ }}
133
+ className="w-full text-left flex items-center justify-between py-2 px-3 rounded text-xs hover:bg-muted transition-colors"
134
+ >
135
+ <div className="flex items-center gap-2 min-w-0">
136
+ {item.icon}
137
+ <div className="truncate">
138
+ <div className="font-semibold text-foreground">{item.label}</div>
139
+ {item.description && (
140
+ <div className="text-[10px] text-muted-foreground">{item.description}</div>
141
+ )}
142
+ </div>
143
+ </div>
144
+ {value === item.value && <Check className="h-4 w-4 text-primary shrink-0" />}
145
+ </button>
146
+ ))
147
+ ) : (
148
+ <div className="p-3 text-center text-xs text-muted-foreground">{emptyMessage}</div>
149
+ )}
150
+
151
+ {hasNextPage && (
152
+ <button
153
+ type="button"
154
+ onClick={() => onFetchNextPage?.()}
155
+ disabled={isFetchingNextPage}
156
+ className="w-full py-1 text-center text-[11px] text-primary font-semibold hover:underline"
157
+ >
158
+ {isFetchingNextPage ? "Loading more..." : "Load more..."}
159
+ </button>
160
+ )}
161
+ </div>
162
+ </div>
163
+ )}
164
+ </div>
165
+ );
166
+ }