hey-pharmacist-ecommerce 1.0.4 → 1.0.6
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 +107 -1
- package/dist/index.d.mts +3636 -316
- package/dist/index.d.ts +3636 -316
- package/dist/index.js +6802 -3865
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6756 -3817
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -14
- package/src/components/AddressFormModal.tsx +171 -0
- package/src/components/CartItem.tsx +17 -12
- package/src/components/FilterChips.tsx +195 -0
- package/src/components/Header.tsx +121 -71
- package/src/components/OrderCard.tsx +18 -25
- package/src/components/ProductCard.tsx +209 -72
- package/src/components/ui/Button.tsx +13 -5
- package/src/components/ui/Card.tsx +46 -0
- package/src/hooks/useAddresses.ts +83 -0
- package/src/hooks/useOrders.ts +37 -19
- package/src/hooks/useProducts.ts +55 -63
- package/src/hooks/useWishlistProducts.ts +75 -0
- package/src/index.ts +3 -19
- package/src/lib/Apis/api.ts +1 -0
- package/src/lib/Apis/apis/cart-api.ts +3 -3
- package/src/lib/Apis/apis/inventory-api.ts +0 -108
- package/src/lib/Apis/apis/stores-api.ts +70 -0
- package/src/lib/Apis/apis/wishlist-api.ts +447 -0
- package/src/lib/Apis/models/cart-item-populated.ts +0 -1
- package/src/lib/Apis/models/create-single-variant-product-dto.ts +3 -10
- package/src/lib/Apis/models/create-variant-dto.ts +26 -33
- package/src/lib/Apis/models/extended-product-dto.ts +20 -24
- package/src/lib/Apis/models/index.ts +2 -1
- package/src/lib/Apis/models/order-time-line-dto.ts +49 -0
- package/src/lib/Apis/models/order.ts +3 -8
- package/src/lib/Apis/models/populated-order.ts +3 -8
- package/src/lib/Apis/models/product-variant.ts +29 -0
- package/src/lib/Apis/models/update-product-variant-dto.ts +16 -23
- package/src/lib/Apis/models/wishlist.ts +51 -0
- package/src/lib/Apis/wrapper.ts +18 -7
- package/src/lib/api-adapter/index.ts +0 -12
- package/src/lib/types/index.ts +16 -61
- package/src/lib/utils/colors.ts +7 -4
- package/src/lib/utils/format.ts +1 -1
- package/src/lib/validations/address.ts +14 -0
- package/src/providers/AuthProvider.tsx +61 -31
- package/src/providers/CartProvider.tsx +18 -28
- package/src/providers/EcommerceProvider.tsx +7 -0
- package/src/providers/FavoritesProvider.tsx +86 -0
- package/src/providers/ThemeProvider.tsx +16 -1
- package/src/providers/WishlistProvider.tsx +174 -0
- package/src/screens/AddressesScreen.tsx +484 -0
- package/src/screens/CartScreen.tsx +120 -84
- package/src/screens/CategoriesScreen.tsx +120 -0
- package/src/screens/CheckoutScreen.tsx +919 -241
- package/src/screens/CurrentOrdersScreen.tsx +125 -61
- package/src/screens/HomeScreen.tsx +209 -0
- package/src/screens/LoginScreen.tsx +133 -88
- package/src/screens/NewAddressScreen.tsx +187 -0
- package/src/screens/OrdersScreen.tsx +162 -50
- package/src/screens/ProductDetailScreen.tsx +641 -190
- package/src/screens/ProfileScreen.tsx +192 -116
- package/src/screens/RegisterScreen.tsx +193 -144
- package/src/screens/SearchResultsScreen.tsx +165 -0
- package/src/screens/ShopScreen.tsx +1110 -146
- package/src/screens/WishlistScreen.tsx +428 -0
- package/src/lib/Apis/models/inventory-paginated-response.ts +0 -75
- package/src/lib/api/auth.ts +0 -81
- package/src/lib/api/cart.ts +0 -42
- package/src/lib/api/orders.ts +0 -53
- package/src/lib/api/products.ts +0 -51
- package/src/lib/api-adapter/auth-adapter.ts +0 -196
- package/src/lib/api-adapter/cart-adapter.ts +0 -193
- package/src/lib/api-adapter/mappers.ts +0 -147
- package/src/lib/api-adapter/orders-adapter.ts +0 -195
- package/src/lib/api-adapter/products-adapter.ts +0 -194
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hey-pharmacist-ecommerce",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Multi-tenant e-commerce package for Next.js",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -12,39 +12,42 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"dev": "next dev",
|
|
14
14
|
"build": "tsup",
|
|
15
|
+
"build:watch": "tsup --watch",
|
|
15
16
|
"lint": "next lint",
|
|
16
|
-
"type-check": "tsc --noEmit"
|
|
17
|
+
"type-check": "tsc --noEmit",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
17
19
|
},
|
|
18
20
|
"dependencies": {
|
|
21
|
+
"@hookform/resolvers": "^3.3.0",
|
|
22
|
+
"@tanstack/react-query": "^5.90.5",
|
|
19
23
|
"axios": "^1.6.0",
|
|
20
|
-
"
|
|
24
|
+
"cookies-next": "^4.0.0",
|
|
25
|
+
"framer-motion": "^10.12.18",
|
|
21
26
|
"lucide-react": "^0.294.0",
|
|
22
|
-
"
|
|
23
|
-
"@hookform/resolvers": "^3.3.0",
|
|
27
|
+
"react-hook-form": "^7.0.0",
|
|
24
28
|
"sonner": "^1.2.0",
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"react-hook-form": "^7.48.0"
|
|
29
|
+
"zod": "^3.22.0",
|
|
30
|
+
"zustand": "^4.4.0"
|
|
28
31
|
},
|
|
29
32
|
"devDependencies": {
|
|
30
33
|
"@types/node": "^20.0.0",
|
|
31
34
|
"@types/react": "^18.2.0",
|
|
32
35
|
"@types/react-dom": "^18.2.0",
|
|
33
|
-
"typescript": "^5.3.0",
|
|
34
|
-
"tailwindcss": "^3.3.0",
|
|
35
|
-
"postcss": "^8.4.0",
|
|
36
36
|
"autoprefixer": "^10.4.0",
|
|
37
|
-
"tsup": "^8.0.0",
|
|
38
37
|
"eslint": "^8.0.0",
|
|
39
38
|
"eslint-config-next": "^14.0.0",
|
|
39
|
+
"next": "^14.0.0",
|
|
40
|
+
"postcss": "^8.4.0",
|
|
40
41
|
"react": "^18.2.0",
|
|
41
42
|
"react-dom": "^18.2.0",
|
|
42
|
-
"
|
|
43
|
+
"tailwindcss": "^3.3.0",
|
|
44
|
+
"tsup": "^8.0.0",
|
|
45
|
+
"typescript": "^5.3.0"
|
|
43
46
|
},
|
|
44
47
|
"peerDependencies": {
|
|
48
|
+
"next": "^14.0.0",
|
|
45
49
|
"react": "^18.0.0",
|
|
46
50
|
"react-dom": "^18.0.0",
|
|
47
|
-
"next": "^14.0.0",
|
|
48
51
|
"react-hook-form": "^7.0.0"
|
|
49
52
|
},
|
|
50
53
|
"keywords": [
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useForm } from 'react-hook-form';
|
|
3
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
4
|
+
import { Button } from '@/components/ui/Button';
|
|
5
|
+
import { Input } from '@/components/ui/Input';
|
|
6
|
+
import { Modal } from '@/components/ui/Modal';
|
|
7
|
+
import { addressSchema, type AddressFormData } from '@/lib/validations/address';
|
|
8
|
+
import { AddressesApi } from '@/lib/Apis/apis/addresses-api';
|
|
9
|
+
import { Address } from '@/lib/Apis';
|
|
10
|
+
import { AXIOS_CONFIG } from '@/lib/Apis/wrapper';
|
|
11
|
+
import { toast } from 'sonner';
|
|
12
|
+
|
|
13
|
+
interface AddressFormModalProps {
|
|
14
|
+
isOpen: boolean;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
onAddressAdded?: (address: any) => void;
|
|
17
|
+
onAddressUpdated?: (address: any) => void;
|
|
18
|
+
initialAddress?: Address | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AddressFormModal({ isOpen, onClose, onAddressAdded, onAddressUpdated, initialAddress }: AddressFormModalProps) {
|
|
22
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
register,
|
|
26
|
+
handleSubmit,
|
|
27
|
+
reset,
|
|
28
|
+
formState: { errors }
|
|
29
|
+
} = useForm<AddressFormData>({
|
|
30
|
+
resolver: zodResolver(addressSchema),
|
|
31
|
+
defaultValues: {
|
|
32
|
+
name: initialAddress?.name || '',
|
|
33
|
+
phone: initialAddress?.phone || '',
|
|
34
|
+
street1: initialAddress?.street1 || '',
|
|
35
|
+
street2: initialAddress?.street2 || '',
|
|
36
|
+
city: initialAddress?.city || '',
|
|
37
|
+
state: initialAddress?.state || '',
|
|
38
|
+
zip: initialAddress?.zip || '',
|
|
39
|
+
country: initialAddress?.country || 'United States',
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Reset form when switching between create/edit
|
|
44
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
45
|
+
useState(() => {
|
|
46
|
+
// Using useState initializer to avoid extra render; react-hook-form defaultValues handle initial load
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const onSubmit = async (data: AddressFormData) => {
|
|
50
|
+
setIsSubmitting(true);
|
|
51
|
+
try {
|
|
52
|
+
const api = new AddressesApi(AXIOS_CONFIG);
|
|
53
|
+
if (initialAddress?.id) {
|
|
54
|
+
const response = await api.updateUserAddress({
|
|
55
|
+
name: data.name,
|
|
56
|
+
street1: data.street1,
|
|
57
|
+
street2: data.street2,
|
|
58
|
+
city: data.city,
|
|
59
|
+
state: data.state,
|
|
60
|
+
zip: data.zip,
|
|
61
|
+
country: data.country,
|
|
62
|
+
phone: data.phone,
|
|
63
|
+
}, initialAddress.id);
|
|
64
|
+
toast.success('Address updated successfully');
|
|
65
|
+
reset();
|
|
66
|
+
onClose();
|
|
67
|
+
if (onAddressUpdated) onAddressUpdated(response.data);
|
|
68
|
+
} else {
|
|
69
|
+
const response = await api.createAddressForUser({
|
|
70
|
+
name: data.name,
|
|
71
|
+
street1: data.street1,
|
|
72
|
+
street2: data.street2,
|
|
73
|
+
city: data.city,
|
|
74
|
+
state: data.state,
|
|
75
|
+
zip: data.zip,
|
|
76
|
+
country: data.country,
|
|
77
|
+
phone: data.phone,
|
|
78
|
+
});
|
|
79
|
+
if (response.status === 201) {
|
|
80
|
+
toast.success('Address added successfully');
|
|
81
|
+
reset();
|
|
82
|
+
onClose();
|
|
83
|
+
if (onAddressAdded) onAddressAdded(response.data);
|
|
84
|
+
} else {
|
|
85
|
+
toast.error('Failed to add address');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
toast.error('Failed to add address');
|
|
90
|
+
} finally {
|
|
91
|
+
setIsSubmitting(false);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Modal
|
|
97
|
+
isOpen={isOpen}
|
|
98
|
+
onClose={onClose}
|
|
99
|
+
title={initialAddress ? 'Edit Address' : 'Add New Address'}
|
|
100
|
+
size="lg"
|
|
101
|
+
>
|
|
102
|
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
|
103
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
104
|
+
<Input
|
|
105
|
+
label="Full name"
|
|
106
|
+
placeholder="John Doe"
|
|
107
|
+
{...register('name')}
|
|
108
|
+
error={errors.name?.message}
|
|
109
|
+
/>
|
|
110
|
+
<Input
|
|
111
|
+
label="Phone number"
|
|
112
|
+
placeholder="+1 (555) 123-4567"
|
|
113
|
+
{...register('phone')}
|
|
114
|
+
error={errors.phone?.message}
|
|
115
|
+
/>
|
|
116
|
+
<div className="md:col-span-2">
|
|
117
|
+
<Input
|
|
118
|
+
label="Address line 1"
|
|
119
|
+
placeholder="123 Main St"
|
|
120
|
+
{...register('street1')}
|
|
121
|
+
error={errors.street1?.message}
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
<div className="md:col-span-2">
|
|
125
|
+
<Input
|
|
126
|
+
label="Address line 2 (optional)"
|
|
127
|
+
placeholder="Apt 4B"
|
|
128
|
+
{...register('street2')}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<Input
|
|
132
|
+
label="City"
|
|
133
|
+
placeholder="New York"
|
|
134
|
+
{...register('city')}
|
|
135
|
+
error={errors.city?.message}
|
|
136
|
+
/>
|
|
137
|
+
<Input
|
|
138
|
+
label="State"
|
|
139
|
+
placeholder="NY"
|
|
140
|
+
{...register('state')}
|
|
141
|
+
error={errors.state?.message}
|
|
142
|
+
/>
|
|
143
|
+
<Input
|
|
144
|
+
label="ZIP code"
|
|
145
|
+
placeholder="10001"
|
|
146
|
+
{...register('zip')}
|
|
147
|
+
error={errors.zip?.message}
|
|
148
|
+
/>
|
|
149
|
+
<Input
|
|
150
|
+
label="Country"
|
|
151
|
+
placeholder="United States"
|
|
152
|
+
{...register('country')}
|
|
153
|
+
error={errors.country?.message}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
<div className="flex justify-end gap-4">
|
|
157
|
+
<Button
|
|
158
|
+
type="button"
|
|
159
|
+
variant="outline"
|
|
160
|
+
onClick={onClose}
|
|
161
|
+
>
|
|
162
|
+
Cancel
|
|
163
|
+
</Button>
|
|
164
|
+
<Button type="submit" disabled={isSubmitting}>
|
|
165
|
+
{isSubmitting ? 'Adding Address...' : 'Add Address'}
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
</form>
|
|
169
|
+
</Modal>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
import React, { useState } from 'react';
|
|
4
4
|
import { motion } from 'framer-motion';
|
|
5
5
|
import { Minus, Plus, Trash2 } from 'lucide-react';
|
|
6
|
-
import { CartItem as CartItemType } from '@/lib/types';
|
|
7
6
|
import { formatPrice } from '@/lib/utils/format';
|
|
8
7
|
import { useCart } from '@/providers/CartProvider';
|
|
9
8
|
import Image from 'next/image';
|
|
9
|
+
import { CartItemPopulated } from '@/lib/Apis';
|
|
10
|
+
|
|
10
11
|
|
|
11
12
|
interface CartItemProps {
|
|
12
|
-
item:
|
|
13
|
+
item: CartItemPopulated;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export function CartItem({ item }: CartItemProps) {
|
|
@@ -20,17 +21,17 @@ export function CartItem({ item }: CartItemProps) {
|
|
|
20
21
|
if (newQuantity < 1) return;
|
|
21
22
|
setIsUpdating(true);
|
|
22
23
|
try {
|
|
23
|
-
await updateQuantity(item.
|
|
24
|
+
await updateQuantity(item.productVariantId, newQuantity);
|
|
24
25
|
} finally {
|
|
25
26
|
setIsUpdating(false);
|
|
26
27
|
}
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
const handleRemove = async () => {
|
|
30
|
-
await removeFromCart(item.
|
|
31
|
+
await removeFromCart(item.productVariantId);
|
|
31
32
|
};
|
|
32
33
|
|
|
33
|
-
const itemTotal = item.
|
|
34
|
+
const itemTotal = item.productVariantData.finalPrice * item.quantity;
|
|
34
35
|
|
|
35
36
|
return (
|
|
36
37
|
<motion.div
|
|
@@ -43,8 +44,8 @@ export function CartItem({ item }: CartItemProps) {
|
|
|
43
44
|
{/* Product Image */}
|
|
44
45
|
<div className="relative w-24 h-24 rounded-lg overflow-hidden flex-shrink-0 bg-gray-100">
|
|
45
46
|
<Image
|
|
46
|
-
src={item.
|
|
47
|
-
alt={item.
|
|
47
|
+
src={item.productVariantData.productMedia[0]?.file || '/placeholder-product.jpg'}
|
|
48
|
+
alt={item.productVariantData.name}
|
|
48
49
|
fill
|
|
49
50
|
className="object-cover"
|
|
50
51
|
/>
|
|
@@ -53,10 +54,10 @@ export function CartItem({ item }: CartItemProps) {
|
|
|
53
54
|
{/* Product Info */}
|
|
54
55
|
<div className="flex-1 min-w-0">
|
|
55
56
|
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
|
56
|
-
{item.
|
|
57
|
+
{item.productVariantData.name}
|
|
57
58
|
</h3>
|
|
58
59
|
<p className="text-sm text-gray-500 mt-1">
|
|
59
|
-
{formatPrice(item.
|
|
60
|
+
{formatPrice(item.productVariantData.finalPrice)} each
|
|
60
61
|
</p>
|
|
61
62
|
|
|
62
63
|
{/* Quantity Controls */}
|
|
@@ -64,17 +65,21 @@ export function CartItem({ item }: CartItemProps) {
|
|
|
64
65
|
<div className="flex items-center border-2 border-gray-200 rounded-lg">
|
|
65
66
|
<button
|
|
66
67
|
onClick={() => handleUpdateQuantity(item.quantity - 1)}
|
|
67
|
-
disabled={isUpdating || item.quantity <=
|
|
68
|
+
disabled={isUpdating || item.quantity <= 0}
|
|
68
69
|
className="p-2 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
69
70
|
>
|
|
70
71
|
<Minus className="w-4 h-4" />
|
|
71
72
|
</button>
|
|
72
73
|
<span className="px-4 font-medium min-w-[3rem] text-center">
|
|
73
|
-
{
|
|
74
|
+
{isUpdating ? (
|
|
75
|
+
<span className="inline-block h-4 w-4 align-middle animate-spin rounded-full border-2 border-gray-300 border-t-gray-600" />
|
|
76
|
+
) : (
|
|
77
|
+
item.quantity
|
|
78
|
+
)}
|
|
74
79
|
</span>
|
|
75
80
|
<button
|
|
76
81
|
onClick={() => handleUpdateQuantity(item.quantity + 1)}
|
|
77
|
-
disabled={isUpdating}
|
|
82
|
+
disabled={isUpdating || item.quantity >= item.productVariantData.inventoryCount}
|
|
78
83
|
className="p-2 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
79
84
|
>
|
|
80
85
|
<Plus className="w-4 h-4" />
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { AnimatePresence, motion } from 'framer-motion';
|
|
3
|
+
import { Check, Search } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
interface FilterChipsProps {
|
|
6
|
+
label: string;
|
|
7
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
8
|
+
filters: string[];
|
|
9
|
+
selected: string;
|
|
10
|
+
onSelect: (value: string) => void;
|
|
11
|
+
maxVisible?: number;
|
|
12
|
+
variant?: 'primary' | 'secondary';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function FilterChips({
|
|
16
|
+
label,
|
|
17
|
+
icon: Icon,
|
|
18
|
+
filters,
|
|
19
|
+
selected,
|
|
20
|
+
onSelect,
|
|
21
|
+
maxVisible = 4,
|
|
22
|
+
variant = 'primary',
|
|
23
|
+
}: FilterChipsProps) {
|
|
24
|
+
const [isOverflowOpen, setIsOverflowOpen] = useState(false);
|
|
25
|
+
const [filterSearchTerm, setFilterSearchTerm] = useState('');
|
|
26
|
+
const overflowMenuRef = useRef<HTMLDivElement | null>(null);
|
|
27
|
+
|
|
28
|
+
const color = variant === 'primary' ? 'primary' : 'secondary';
|
|
29
|
+
|
|
30
|
+
const { visibleFilters, overflowFilters } = useMemo(() => {
|
|
31
|
+
const basePrimary = filters.slice(0, maxVisible);
|
|
32
|
+
|
|
33
|
+
if (basePrimary.includes(selected)) {
|
|
34
|
+
return {
|
|
35
|
+
visibleFilters: basePrimary,
|
|
36
|
+
overflowFilters: filters.filter((filter) => !basePrimary.includes(filter)),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const adjustedPrimary = [
|
|
41
|
+
...basePrimary.slice(0, maxVisible - 1),
|
|
42
|
+
selected,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const uniquePrimary = adjustedPrimary.filter(
|
|
46
|
+
(filter, index, self) => self.indexOf(filter) === index
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
visibleFilters: uniquePrimary,
|
|
51
|
+
overflowFilters: filters.filter((filter) => !uniquePrimary.includes(filter)),
|
|
52
|
+
};
|
|
53
|
+
}, [filters, maxVisible, selected]);
|
|
54
|
+
|
|
55
|
+
const filteredOverflowFilters = useMemo(() => {
|
|
56
|
+
if (!filterSearchTerm.trim()) return overflowFilters;
|
|
57
|
+
return overflowFilters.filter((filter) =>
|
|
58
|
+
filter.toLowerCase().includes(filterSearchTerm.toLowerCase())
|
|
59
|
+
);
|
|
60
|
+
}, [filterSearchTerm, overflowFilters]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!isOverflowOpen) {
|
|
64
|
+
setFilterSearchTerm('');
|
|
65
|
+
}
|
|
66
|
+
}, [isOverflowOpen]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
function handleClickOutside(event: MouseEvent) {
|
|
70
|
+
if (overflowMenuRef.current && !overflowMenuRef.current.contains(event.target as Node)) {
|
|
71
|
+
setIsOverflowOpen(false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (isOverflowOpen) {
|
|
76
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return () => {
|
|
80
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
81
|
+
};
|
|
82
|
+
}, [isOverflowOpen]);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex flex-col gap-3 md:flex-row md:items-center md:gap-4">
|
|
86
|
+
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-600">
|
|
87
|
+
<Icon className="h-4 w-4" />
|
|
88
|
+
{label}
|
|
89
|
+
</span>
|
|
90
|
+
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-3">
|
|
91
|
+
<div className="flex flex-wrap gap-2">
|
|
92
|
+
{visibleFilters.map((filter) => (
|
|
93
|
+
<button
|
|
94
|
+
key={filter}
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={() => onSelect(filter)}
|
|
97
|
+
className={`rounded-full border px-3 py-1 text-sm font-medium transition ${
|
|
98
|
+
selected === filter
|
|
99
|
+
? `border-${color}-600 bg-${color}-600 text-white shadow-lg shadow-${color}-500/30`
|
|
100
|
+
: `border-slate-200 bg-slate-50 text-slate-600 hover:border-${color}-300 hover:text-${color}-600`
|
|
101
|
+
}`}
|
|
102
|
+
>
|
|
103
|
+
{filter}
|
|
104
|
+
</button>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{overflowFilters.length > 0 && (
|
|
109
|
+
<div className="relative" ref={overflowMenuRef}>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={() => setIsOverflowOpen((prev) => !prev)}
|
|
113
|
+
className={`flex items-center gap-2 rounded-full border px-3 py-1 text-sm font-medium transition ${
|
|
114
|
+
overflowFilters.includes(selected)
|
|
115
|
+
? `border-${color}-600 bg-${color}-50 text-${color}-700 shadow-lg shadow-${color}-500/20`
|
|
116
|
+
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300'
|
|
117
|
+
}`}
|
|
118
|
+
>
|
|
119
|
+
<span>{overflowFilters.includes(selected) ? selected : 'More'}</span>
|
|
120
|
+
<span className={`inline-flex h-5 min-w-[1.5rem] items-center justify-center rounded-full bg-${color}-100 px-1 text-xs font-semibold text-${color}-600`}>
|
|
121
|
+
{overflowFilters.length}
|
|
122
|
+
</span>
|
|
123
|
+
</button>
|
|
124
|
+
|
|
125
|
+
<AnimatePresence>
|
|
126
|
+
{isOverflowOpen && (
|
|
127
|
+
<motion.div
|
|
128
|
+
initial={{ opacity: 0, y: 8 }}
|
|
129
|
+
animate={{ opacity: 1, y: 0 }}
|
|
130
|
+
exit={{ opacity: 0, y: 8 }}
|
|
131
|
+
transition={{ duration: 0.15 }}
|
|
132
|
+
className="absolute right-0 z-50 mt-2 w-64 rounded-2xl border border-slate-100 bg-white shadow-xl shadow-primary-50"
|
|
133
|
+
>
|
|
134
|
+
<div className="border-b border-slate-100 px-4 py-3">
|
|
135
|
+
<div className="relative">
|
|
136
|
+
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
137
|
+
<input
|
|
138
|
+
type="text"
|
|
139
|
+
placeholder={`Search ${label.toLowerCase()}`}
|
|
140
|
+
value={filterSearchTerm}
|
|
141
|
+
onChange={(event) => setFilterSearchTerm(event.target.value)}
|
|
142
|
+
className="w-full rounded-full border border-slate-200 bg-slate-50 py-2 pl-9 pr-3 text-sm text-slate-600 outline-none transition focus:border-primary-300 focus:bg-white focus:ring-2 focus:ring-primary-200"
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="max-h-60 overflow-y-auto px-2 py-2">
|
|
147
|
+
{filteredOverflowFilters.length > 0 ? (
|
|
148
|
+
filteredOverflowFilters.map((filter) => (
|
|
149
|
+
<button
|
|
150
|
+
key={filter}
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={() => {
|
|
153
|
+
onSelect(filter);
|
|
154
|
+
setIsOverflowOpen(false);
|
|
155
|
+
}}
|
|
156
|
+
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-sm font-medium transition ${
|
|
157
|
+
selected === filter
|
|
158
|
+
? `bg-${color}-600 text-white shadow-lg shadow-${color}-500/30`
|
|
159
|
+
: 'text-slate-600 hover:bg-slate-100'
|
|
160
|
+
}`}
|
|
161
|
+
>
|
|
162
|
+
<span>{filter}</span>
|
|
163
|
+
{selected === filter && <Check className="h-4 w-4" />}
|
|
164
|
+
</button>
|
|
165
|
+
))
|
|
166
|
+
) : (
|
|
167
|
+
<p className="px-3 py-4 text-sm text-slate-500">No items found.</p>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
<div className="flex items-center justify-between gap-2 border-t border-slate-100 px-4 py-3">
|
|
171
|
+
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
|
172
|
+
Quick actions
|
|
173
|
+
</span>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
onClick={() => {
|
|
177
|
+
onSelect('All');
|
|
178
|
+
setIsOverflowOpen(false);
|
|
179
|
+
}}
|
|
180
|
+
className="text-xs font-semibold uppercase tracking-wide text-primary-600 hover:text-primary-700"
|
|
181
|
+
>
|
|
182
|
+
Reset to All
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
</motion.div>
|
|
186
|
+
)}
|
|
187
|
+
</AnimatePresence>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|