@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.
- package/README.md +42 -15
- package/dist/AddressAutocomplete-CjcqPElD.d.mts +46 -0
- package/dist/AddressAutocomplete-CvJHuRkf.d.ts +46 -0
- package/dist/components/index.d.mts +497 -0
- package/dist/components/index.d.ts +497 -0
- package/dist/components/index.js +4217 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/index.mjs +4194 -0
- package/dist/components/index.mjs.map +1 -0
- package/dist/config/server.d.mts +26 -0
- package/dist/config/server.d.ts +26 -0
- package/dist/config/server.js +38 -0
- package/dist/config/server.js.map +1 -0
- package/dist/config/server.mjs +13 -0
- package/dist/config/server.mjs.map +1 -0
- package/dist/hooks/index.d.mts +393 -160
- package/dist/hooks/index.d.ts +393 -160
- package/dist/hooks/index.js +2792 -2137
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/index.mjs +2734 -2121
- package/dist/hooks/index.mjs.map +1 -1
- package/dist/index-CNW_LINy.d.mts +578 -0
- package/dist/index-CNW_LINy.d.ts +578 -0
- package/dist/{index-C1chUA3P.d.mts → index-F7N6UItS.d.mts} +1 -1
- package/dist/{index-C1chUA3P.d.ts → index-F7N6UItS.d.ts} +1 -1
- package/dist/index-NkI10J_E.d.mts +40 -0
- package/dist/index-NkI10J_E.d.ts +40 -0
- package/dist/index.d.mts +20437 -103
- package/dist/index.d.ts +20437 -103
- package/dist/index.js +2111 -1802
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2006 -1786
- package/dist/index.mjs.map +1 -1
- package/dist/media.service-Cx7dQjzX.d.mts +1393 -0
- package/dist/media.service-mrBFJ2UQ.d.ts +1393 -0
- package/dist/services/index.d.mts +908 -2
- package/dist/services/index.d.ts +908 -2
- package/dist/services/index.js +1492 -1686
- package/dist/services/index.js.map +1 -1
- package/dist/services/index.mjs +1431 -1670
- package/dist/services/index.mjs.map +1 -1
- package/package.json +46 -6
- package/src/components/bespoke/BespokeAvailabilityBadge.tsx +32 -0
- package/src/components/bespoke/BespokeProgressTracker.tsx +58 -0
- package/src/components/bespoke/index.ts +3 -0
- package/src/components/booking/SlotPicker.tsx +97 -0
- package/src/components/booking/index.ts +1 -0
- package/src/components/cart/CartItem.tsx +81 -0
- package/src/components/cart/CartSidebar.tsx +115 -0
- package/src/components/cart/CartTypeMismatchBanner.tsx +55 -0
- package/src/components/cart/index.ts +3 -0
- package/src/components/index.ts +8 -0
- package/src/components/orders/DeliverableReviewer.tsx +139 -0
- package/src/components/orders/index.ts +1 -0
- package/src/components/product/ProductCard.tsx +350 -0
- package/src/components/product/ProductGrid.tsx +84 -0
- package/src/components/product/index.ts +2 -0
- package/src/components/shared/AddressAutocomplete.tsx +212 -0
- package/src/components/shared/AnalyticsScripts.tsx +56 -0
- package/src/components/shared/CookieConsentBanner.tsx +98 -0
- package/src/components/shared/ImageEditorModal.tsx +1066 -0
- package/src/components/shared/ImageViewerModal.tsx +598 -0
- package/src/components/shared/LucideReactIcon.tsx +52 -0
- package/src/components/shared/MapPinDrop.tsx +101 -0
- package/src/components/shared/MultiSelectPicker.tsx +193 -0
- package/src/components/shared/OfflineBanner.tsx +46 -0
- package/src/components/shared/SearchableInput.tsx +153 -0
- package/src/components/shared/SearchablePopover.tsx +166 -0
- package/src/components/shared/SmartImage.tsx +311 -0
- package/src/components/shared/TruncatedValue.tsx +27 -0
- package/src/components/shared/UnsavedChangesGuard.tsx +138 -0
- package/src/components/shared/index.ts +15 -0
- package/src/components/skeletons/CardSkeleton.tsx +26 -0
- package/src/components/skeletons/CartItemSkeleton.tsx +26 -0
- package/src/components/skeletons/OrderItemSkeleton.tsx +26 -0
- package/src/components/skeletons/ProductCardSkeleton.tsx +26 -0
- package/src/components/skeletons/TableSkeleton.tsx +42 -0
- package/src/components/skeletons/index.ts +5 -0
- package/src/config/index.ts +22 -4
- package/src/config/server.ts +45 -0
- package/src/context/sdk-context.tsx +5 -2
- package/src/generated/auth-openapi.ts +2935 -0
- package/src/generated/cart-openapi.ts +652 -0
- package/src/generated/cms-openapi.ts +1668 -0
- package/src/generated/crm-openapi.ts +986 -0
- package/src/generated/graphql.ts +126 -0
- package/src/generated/index.ts +11 -0
- package/src/generated/media-openapi.ts +613 -0
- package/src/generated/notification-openapi.ts +862 -0
- package/src/generated/payment-openapi.ts +2713 -0
- package/src/generated/store-openapi.ts +9510 -0
- package/src/graphql/operations/collections.graphql +32 -0
- package/src/graphql/operations/products.graphql +197 -0
- package/src/graphql/operations/wishlist.graphql +25 -0
- package/src/hooks/index.ts +9 -2
- package/src/hooks/use-auth.ts +163 -69
- package/src/hooks/use-bespoke-availability.ts +39 -22
- package/src/hooks/use-business-config.ts +51 -0
- package/src/hooks/use-cart.ts +310 -238
- package/src/hooks/use-cms.ts +107 -127
- package/src/hooks/use-collections.ts +35 -55
- package/src/hooks/use-consultations.ts +9 -136
- package/src/hooks/use-countries.ts +19 -28
- package/src/hooks/use-currencies.ts +8 -20
- package/src/hooks/use-customer-address.ts +64 -0
- package/src/hooks/use-feedback.ts +34 -0
- package/src/hooks/use-languages.ts +26 -39
- package/src/hooks/use-media-upload.ts +23 -0
- package/src/hooks/use-notification-stream.ts +88 -0
- package/src/hooks/use-notifications.ts +163 -0
- package/src/hooks/use-order-stream.ts +29 -29
- package/src/hooks/use-orders.ts +91 -73
- package/src/hooks/use-payment.ts +116 -84
- package/src/hooks/use-products.ts +65 -269
- package/src/hooks/use-push-notifications.ts +122 -0
- package/src/hooks/use-reviews.ts +175 -0
- package/src/hooks/use-service-booking.ts +109 -132
- package/src/hooks/use-states.ts +16 -30
- package/src/hooks/use-wishlist.ts +44 -69
- package/src/index.ts +15 -11
- package/src/services/auth.service.ts +422 -132
- package/src/services/bespoke.service.ts +69 -10
- package/src/services/cart.service.ts +155 -186
- package/src/services/cms.service.ts +41 -50
- package/src/services/collection.service.ts +25 -63
- package/src/services/config.service.ts +10 -21
- package/src/services/consultation.service.ts +36 -93
- package/src/services/feedback.service.ts +76 -0
- package/src/services/index.ts +7 -1
- package/src/services/legal.service.ts +62 -0
- package/src/services/logistics.service.ts +235 -53
- package/src/services/media.service.ts +115 -0
- package/src/services/notifications.service.ts +204 -20
- package/src/services/order.service.ts +498 -127
- package/src/services/payment.service.ts +149 -100
- package/src/services/product.service.ts +157 -814
- package/src/services/review.service.ts +211 -0
- package/src/services/service-booking.service.ts +198 -99
- package/src/services/wishlist.service.ts +27 -74
- package/src/types/auth/index.ts +161 -18
- package/src/types/business/index.ts +9 -1
- package/src/types/cms/index.ts +91 -6
- package/src/types/cms/legal.ts +18 -0
- package/src/types/consultations/index.ts +60 -68
- package/src/types/index.ts +5 -3
- package/src/types/media/index.ts +35 -3
- package/src/types/payments/index.ts +80 -16
- package/src/types/reviews/index.ts +116 -0
- package/src/types/services/index.ts +113 -38
- package/src/types/store/checkout.ts +53 -0
- package/src/types/store/index.ts +273 -52
- package/src/types/store/invoice.ts +31 -0
- package/src/types/store/shipping.ts +84 -3
- package/src/types/ui/index.ts +1 -1
- package/src/utils/checkout.ts +19 -3
- package/src/utils/cookieConsent.ts +34 -0
- package/src/utils/format.ts +408 -0
- package/src/utils/geolocation.ts +134 -0
- package/src/utils/graphql-url.ts +47 -0
- package/src/utils/guest-session.ts +59 -0
- package/src/utils/http-client.ts +147 -0
- package/src/utils/index.ts +11 -4
- package/src/utils/status-colors.ts +79 -0
- package/src/utils/token-refresh.ts +115 -0
- package/src/utils/validators.ts +13 -0
- package/src/utils/youtube.ts +18 -0
- package/dist/index-C5YO9gTl.d.mts +0 -507
- package/dist/index-C5YO9gTl.d.ts +0 -507
- package/dist/index-CefG7_xq.d.mts +0 -793
- package/dist/index-DeHCf3zI.d.ts +0 -793
- package/src/graphql/collections.graphql.ts +0 -48
- package/src/graphql/index.ts +0 -7
- package/src/graphql/products.graphql.ts +0 -860
- package/src/graphql/wishlist.graphql.ts +0 -34
- package/src/hooks/use-consultation-stream.ts +0 -142
- 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
|
+
}
|