hey-pharmacist-ecommerce 1.1.28 → 1.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +10552 -1370
- package/dist/index.d.ts +10552 -1370
- package/dist/index.js +4696 -1281
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4640 -1283
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/AccountOrdersTab.tsx +1 -1
- package/src/components/AccountSettingsTab.tsx +88 -6
- package/src/components/CartItem.tsx +1 -1
- package/src/components/Header.tsx +8 -2
- package/src/components/OrderCard.tsx +4 -4
- package/src/components/ProductCard.tsx +59 -42
- package/src/components/QuickViewModal.tsx +13 -13
- package/src/hooks/useAddresses.ts +4 -1
- package/src/hooks/usePaymentMethods.ts +26 -31
- package/src/hooks/useProducts.ts +63 -64
- package/src/hooks/useStoreCapabilities.ts +87 -0
- package/src/hooks/useWishlistProducts.ts +4 -5
- package/src/index.ts +6 -0
- package/src/lib/Apis/api.ts +0 -1
- package/src/lib/Apis/apis/auth-api.ts +37 -36
- package/src/lib/Apis/apis/categories-api.ts +97 -0
- package/src/lib/Apis/apis/products-api.ts +942 -405
- package/src/lib/Apis/apis/shipping-api.ts +105 -0
- package/src/lib/Apis/apis/stores-api.ts +356 -0
- package/src/lib/Apis/apis/sub-categories-api.ts +97 -0
- package/src/lib/Apis/apis/users-api.ts +8 -8
- package/src/lib/Apis/models/address-created-request.ts +0 -12
- package/src/lib/Apis/models/address.ts +0 -12
- package/src/lib/Apis/models/api-key-info-dto.ts +49 -0
- package/src/lib/Apis/models/category-populated.ts +0 -12
- package/src/lib/Apis/models/category-sub-category-populated.ts +2 -2
- package/src/lib/Apis/models/category.ts +0 -18
- package/src/lib/Apis/models/{table-cell-dto.ts → change-password-dto.ts} +6 -6
- package/src/lib/Apis/models/create-address-dto.ts +0 -12
- package/src/lib/Apis/models/create-discount-dto.ts +0 -8
- package/src/lib/Apis/models/create-product-dto.ts +30 -23
- package/src/lib/Apis/models/create-store-address-dto.ts +0 -12
- package/src/lib/Apis/models/create-store-dto-settings.ts +51 -0
- package/src/lib/Apis/models/create-store-dto.ts +7 -0
- package/src/lib/Apis/models/create-sub-category-dto.ts +6 -0
- package/src/lib/Apis/models/create-variant-dto.ts +26 -32
- package/src/lib/Apis/models/discount.ts +0 -8
- package/src/lib/Apis/models/index.ts +16 -7
- package/src/lib/Apis/models/paginated-products-dto.ts +6 -6
- package/src/lib/Apis/models/populated-discount.ts +0 -8
- package/src/lib/Apis/models/product-summary.ts +69 -0
- package/src/lib/Apis/models/product-variant.ts +31 -68
- package/src/lib/Apis/models/product.ts +138 -0
- package/src/lib/Apis/models/products-insights-dto.ts +12 -0
- package/src/lib/Apis/models/reorder-categories-dto.ts +27 -0
- package/src/lib/Apis/models/reorder-products-dto.ts +49 -0
- package/src/lib/Apis/models/{table-dto.ts → reorder-products-success-response-dto.ts} +8 -9
- package/src/lib/Apis/models/reorder-subcategories-dto.ts +33 -0
- package/src/lib/Apis/models/{shallow-parent-category-dto.ts → reorder-success-response-dto.ts} +7 -7
- package/src/lib/Apis/models/shipment-with-order.ts +18 -0
- package/src/lib/Apis/models/shipment.ts +18 -0
- package/src/lib/Apis/models/single-product-media.ts +0 -12
- package/src/lib/Apis/models/store-api-keys-response-dto.ts +34 -0
- package/src/lib/Apis/models/store-capabilities-dto.ts +63 -0
- package/src/lib/Apis/models/store-entity.ts +7 -0
- package/src/lib/Apis/models/store.ts +7 -0
- package/src/lib/Apis/models/sub-category.ts +6 -12
- package/src/lib/Apis/models/update-address-dto.ts +0 -12
- package/src/lib/Apis/models/update-api-keys-dto.ts +39 -0
- package/src/lib/Apis/models/update-discount-dto.ts +0 -8
- package/src/lib/Apis/models/update-manual-shipment-status-dto.ts +47 -0
- package/src/lib/Apis/models/update-product-dto.ts +30 -19
- package/src/lib/Apis/models/update-store-dto.ts +7 -0
- package/src/lib/Apis/models/update-sub-category-dto.ts +6 -0
- package/src/lib/Apis/models/{update-product-variant-dto.ts → update-variant-dto.ts} +46 -46
- package/src/lib/Apis/models/variant-id-inventory-body.ts +27 -0
- package/src/lib/api-adapter/config.ts +53 -0
- package/src/lib/validations/address.ts +1 -1
- package/src/providers/FavoritesProvider.tsx +5 -5
- package/src/providers/WishlistProvider.tsx +4 -4
- package/src/screens/CartScreen.tsx +1 -1
- package/src/screens/ChangePasswordScreen.tsx +2 -6
- package/src/screens/CheckoutScreen.tsx +402 -288
- package/src/screens/ForgotPasswordScreen.tsx +153 -0
- package/src/screens/ProductDetailScreen.tsx +51 -60
- package/src/screens/RegisterScreen.tsx +31 -31
- package/src/screens/ResetPasswordScreen.tsx +208 -0
- package/src/screens/SearchResultsScreen.tsx +264 -26
- package/src/screens/ShopScreen.tsx +42 -45
- package/src/screens/WishlistScreen.tsx +35 -31
- package/src/lib/Apis/apis/product-variants-api.ts +0 -552
- package/src/lib/Apis/models/create-single-variant-product-dto.ts +0 -154
- package/src/lib/Apis/models/extended-product-dto.ts +0 -206
- package/src/lib/Apis/models/frequently-bought-product-dto.ts +0 -71
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
import { motion } from 'framer-motion';
|
|
5
|
+
import { useForm } from 'react-hook-form';
|
|
6
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { useRouter, useParams } from 'next/navigation';
|
|
9
|
+
import Link from 'next/link';
|
|
10
|
+
import { Eye, EyeOff, Lock, ShieldCheck } from 'lucide-react';
|
|
11
|
+
import { Input } from '@/components/ui/Input';
|
|
12
|
+
import { Button } from '@/components/ui/Button';
|
|
13
|
+
import { AuthApi } from '@/lib/Apis/apis/auth-api';
|
|
14
|
+
import { AXIOS_CONFIG } from '@/lib/Apis/sharedConfig';
|
|
15
|
+
import { useBasePath } from '@/providers/BasePathProvider';
|
|
16
|
+
|
|
17
|
+
const resetPasswordSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
|
20
|
+
confirmPassword: z.string().min(8, 'Confirm your new password'),
|
|
21
|
+
})
|
|
22
|
+
.refine((data) => data.newPassword === data.confirmPassword, {
|
|
23
|
+
path: ['confirmPassword'],
|
|
24
|
+
message: 'Passwords do not match',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
|
|
28
|
+
|
|
29
|
+
export function ResetPasswordScreen() {
|
|
30
|
+
const router = useRouter();
|
|
31
|
+
const { buildPath } = useBasePath();
|
|
32
|
+
const params = useParams();
|
|
33
|
+
const token = params?.token as string | undefined;
|
|
34
|
+
const storeId = params?.storeId as string | undefined;
|
|
35
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
36
|
+
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
37
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
38
|
+
const [status, setStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(
|
|
39
|
+
null
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const {
|
|
43
|
+
register,
|
|
44
|
+
handleSubmit,
|
|
45
|
+
formState: { errors },
|
|
46
|
+
} = useForm<ResetPasswordFormData>({
|
|
47
|
+
resolver: zodResolver(resetPasswordSchema),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!token) {
|
|
52
|
+
setStatus({
|
|
53
|
+
type: 'error',
|
|
54
|
+
message: 'Invalid or missing reset token. Please request a new password reset link.',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}, [token]);
|
|
58
|
+
|
|
59
|
+
const onSubmit = async (data: ResetPasswordFormData) => {
|
|
60
|
+
if (!token) {
|
|
61
|
+
setStatus({
|
|
62
|
+
type: 'error',
|
|
63
|
+
message: 'Reset token is missing. Please use the link from your email.',
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setIsSubmitting(true);
|
|
69
|
+
setStatus(null);
|
|
70
|
+
try {
|
|
71
|
+
const authApi = new AuthApi(AXIOS_CONFIG);
|
|
72
|
+
if (!storeId) {
|
|
73
|
+
setStatus({
|
|
74
|
+
type: 'error',
|
|
75
|
+
message: 'Store ID is missing. Please use the link from your email.',
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
await authApi.resetPassword(data.newPassword, token, storeId);
|
|
80
|
+
setStatus({
|
|
81
|
+
type: 'success',
|
|
82
|
+
message: 'Password reset successfully! Redirecting to login...',
|
|
83
|
+
});
|
|
84
|
+
setTimeout(() => router.push(buildPath('/login')), 2000);
|
|
85
|
+
} catch (error: any) {
|
|
86
|
+
setStatus({
|
|
87
|
+
type: 'error',
|
|
88
|
+
message:
|
|
89
|
+
error?.response?.data?.message ||
|
|
90
|
+
'Unable to reset password. The link may have expired.',
|
|
91
|
+
});
|
|
92
|
+
} finally {
|
|
93
|
+
setIsSubmitting(false);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="min-h-screen bg-linear-to-b from-[#F8FAFC] to-[#EBF4FB]">
|
|
99
|
+
<div className="grid min-h-screen overflow-hidden pb-12">
|
|
100
|
+
<motion.section
|
|
101
|
+
initial={{ opacity: 0, x: 24 }}
|
|
102
|
+
animate={{ opacity: 1, x: 0 }}
|
|
103
|
+
transition={{ duration: 0.4 }}
|
|
104
|
+
className="flex items-center justify-center px-6 py-12 lg:px-16"
|
|
105
|
+
>
|
|
106
|
+
<div className="w-full max-w-lg space-y-10 text-center">
|
|
107
|
+
<div className="space-y-2">
|
|
108
|
+
<Lock
|
|
109
|
+
strokeWidth={2}
|
|
110
|
+
className="h-16 w-16 mx-auto text-white rounded-full bg-secondary m-2 mb-4 px-4"
|
|
111
|
+
/>
|
|
112
|
+
<h2 className="text-4xl text-secondary">Reset Password</h2>
|
|
113
|
+
<p className="text-sm text-muted">
|
|
114
|
+
Enter your new password below to reset your account password.
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<form
|
|
119
|
+
onSubmit={handleSubmit(onSubmit)}
|
|
120
|
+
className="space-y-6 rounded-3xl border bg-white p-8"
|
|
121
|
+
style={{
|
|
122
|
+
boxShadow: '0px 4px 6px -4px #0000001A, 0px 10px 15px -3px #0000001A',
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{status && (
|
|
126
|
+
<div
|
|
127
|
+
className={`flex items-start gap-2 rounded-2xl border px-4 py-3 text-sm ${status.type === 'success'
|
|
128
|
+
? 'border-green-200 bg-green-50 text-green-800'
|
|
129
|
+
: 'border-red-200 bg-red-50 text-red-700'
|
|
130
|
+
}`}
|
|
131
|
+
>
|
|
132
|
+
<span className="mt-[2px] text-base">{status.type === 'success' ? '✔' : '!'}</span>
|
|
133
|
+
<span>{status.message}</span>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
<div className="relative text-start text-secondary">
|
|
138
|
+
<h2 className="text-sm text-secondary mb-3">
|
|
139
|
+
New Password <span className="text-primary-500">*</span>
|
|
140
|
+
</h2>
|
|
141
|
+
<Input
|
|
142
|
+
type={showPassword ? 'text' : 'password'}
|
|
143
|
+
placeholder="Enter new password (min. 8 characters)"
|
|
144
|
+
{...register('newPassword')}
|
|
145
|
+
error={errors.newPassword?.message}
|
|
146
|
+
className="text-secondary"
|
|
147
|
+
/>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={() => setShowPassword((prev) => !prev)}
|
|
151
|
+
className="absolute right-3 top-[42px] text-slate-400 transition hover:text-slate-600"
|
|
152
|
+
>
|
|
153
|
+
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<div className="relative text-start text-secondary">
|
|
158
|
+
<h2 className="text-sm text-secondary mb-3">
|
|
159
|
+
Confirm Password <span className="text-primary-500">*</span>
|
|
160
|
+
</h2>
|
|
161
|
+
<Input
|
|
162
|
+
type={showConfirmPassword ? 'text' : 'password'}
|
|
163
|
+
placeholder="Re-enter new password"
|
|
164
|
+
{...register('confirmPassword')}
|
|
165
|
+
error={errors.confirmPassword?.message}
|
|
166
|
+
className="text-secondary"
|
|
167
|
+
/>
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
onClick={() => setShowConfirmPassword((prev) => !prev)}
|
|
171
|
+
className="absolute right-3 top-[42px] text-slate-400 transition hover:text-slate-600"
|
|
172
|
+
>
|
|
173
|
+
{showConfirmPassword ? (
|
|
174
|
+
<EyeOff className="h-5 w-5" />
|
|
175
|
+
) : (
|
|
176
|
+
<Eye className="h-5 w-5" />
|
|
177
|
+
)}
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<button
|
|
182
|
+
type="submit"
|
|
183
|
+
disabled={isSubmitting || !token}
|
|
184
|
+
className="w-full bg-secondary hover:opacity-80 text-white font-medium py-3 px-4 rounded-lg transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
|
|
185
|
+
>
|
|
186
|
+
{isSubmitting ? 'Resetting Password...' : 'Reset Password'}
|
|
187
|
+
</button>
|
|
188
|
+
|
|
189
|
+
<div className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
|
190
|
+
<ShieldCheck className="h-4 w-4 text-primary-600" />
|
|
191
|
+
Use a strong password that you haven't used elsewhere.
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<div className="pt-4 border-t border-slate-200">
|
|
195
|
+
<Link
|
|
196
|
+
href={buildPath('/login')}
|
|
197
|
+
className="text-sm font-medium text-primary transition hover:opacity-80"
|
|
198
|
+
>
|
|
199
|
+
Back to login
|
|
200
|
+
</Link>
|
|
201
|
+
</div>
|
|
202
|
+
</form>
|
|
203
|
+
</div>
|
|
204
|
+
</motion.section>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -1,87 +1,204 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useEffect, useState, useCallback, useMemo } from 'react';
|
|
4
4
|
import { useSearchParams } from 'next/navigation';
|
|
5
5
|
import { ProductsApi } from '@/lib/Apis';
|
|
6
|
-
import {
|
|
6
|
+
import { Product } from '@/lib/Apis';
|
|
7
7
|
import { ProductCard } from '@/components/ProductCard';
|
|
8
8
|
import { Skeleton } from '@/components/ui/Skeleton';
|
|
9
9
|
import { Input } from '@/components/ui/Input';
|
|
10
|
-
import { Search, X } from 'lucide-react';
|
|
10
|
+
import { Search, X, TrendingUp, Clock } from 'lucide-react';
|
|
11
11
|
import Link from 'next/link';
|
|
12
12
|
import { AXIOS_CONFIG } from '@/lib/Apis/wrapper';
|
|
13
13
|
import { useWishlist } from '@/providers/WishlistProvider';
|
|
14
14
|
import { useRouter } from 'next/navigation';
|
|
15
15
|
import { useBasePath } from '@/providers/BasePathProvider';
|
|
16
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
16
17
|
|
|
17
18
|
export default function SearchPage() {
|
|
18
19
|
const router = useRouter();
|
|
19
20
|
const { buildPath } = useBasePath();
|
|
20
21
|
const searchParams = useSearchParams();
|
|
21
22
|
const searchQuery = searchParams.get('q') || '';
|
|
22
|
-
const [products, setProducts] = useState<
|
|
23
|
+
const [products, setProducts] = useState<Product[]>([]);
|
|
23
24
|
const [isLoading, setIsLoading] = useState(true);
|
|
24
25
|
const [searchInput, setSearchInput] = useState(searchQuery);
|
|
25
26
|
const [hasSearched, setHasSearched] = useState(false);
|
|
26
27
|
const { isInWishlist } = useWishlist();
|
|
28
|
+
const [suggestions, setSuggestions] = useState<string[]>([]);
|
|
29
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
30
|
+
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
|
31
|
+
|
|
32
|
+
// Load recent searches from localStorage
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const stored = localStorage.getItem('recent_searches');
|
|
35
|
+
if (stored) {
|
|
36
|
+
try {
|
|
37
|
+
setRecentSearches(JSON.parse(stored));
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error('Failed to parse recent searches', e);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
// Save search query to recent searches
|
|
45
|
+
const saveRecentSearch = useCallback((query: string) => {
|
|
46
|
+
if (!query.trim()) return;
|
|
47
|
+
|
|
48
|
+
setRecentSearches((prev) => {
|
|
49
|
+
const updated = [query, ...prev.filter(s => s !== query)].slice(0, 5);
|
|
50
|
+
localStorage.setItem('recent_searches', JSON.stringify(updated));
|
|
51
|
+
return updated;
|
|
52
|
+
});
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
// Generate suggestions based on product data
|
|
56
|
+
const generateSuggestions = useMemo(() => {
|
|
57
|
+
if (!searchInput.trim() || searchInput.length < 2) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const query = searchInput.toLowerCase().trim();
|
|
62
|
+
const suggestionSet = new Set<string>();
|
|
63
|
+
|
|
64
|
+
// Extract suggestions from product names, brands, categories, and tags
|
|
65
|
+
products.forEach((product) => {
|
|
66
|
+
// Product name matches
|
|
67
|
+
if (product.name?.toLowerCase().includes(query)) {
|
|
68
|
+
suggestionSet.add(product.name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Brand matches
|
|
72
|
+
if (product.brand?.toLowerCase().includes(query)) {
|
|
73
|
+
suggestionSet.add(product.brand);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Tag matches
|
|
77
|
+
product.tags?.forEach((tag) => {
|
|
78
|
+
if (tag.toLowerCase().includes(query)) {
|
|
79
|
+
suggestionSet.add(tag);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return Array.from(suggestionSet).slice(0, 5);
|
|
85
|
+
}, [searchInput, products]);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
setSuggestions(generateSuggestions);
|
|
89
|
+
}, [generateSuggestions]);
|
|
90
|
+
|
|
91
|
+
// Sanitize search input to handle special characters
|
|
92
|
+
const sanitizeSearchInput = useCallback((input: string): string => {
|
|
93
|
+
// Remove leading/trailing whitespace
|
|
94
|
+
let sanitized = input.trim();
|
|
95
|
+
|
|
96
|
+
// Replace multiple spaces with single space
|
|
97
|
+
sanitized = sanitized.replace(/\s+/g, ' ');
|
|
98
|
+
|
|
99
|
+
// Escape special regex characters for safe processing
|
|
100
|
+
// but keep the original input for search API
|
|
101
|
+
return sanitized;
|
|
102
|
+
}, []);
|
|
27
103
|
|
|
28
104
|
useEffect(() => {
|
|
29
105
|
const fetchSearchResults = async () => {
|
|
30
|
-
|
|
106
|
+
const sanitizedQuery = sanitizeSearchInput(searchQuery);
|
|
107
|
+
|
|
108
|
+
if (!sanitizedQuery) {
|
|
31
109
|
setProducts([]);
|
|
32
110
|
setIsLoading(false);
|
|
111
|
+
setHasSearched(false);
|
|
33
112
|
return;
|
|
34
113
|
}
|
|
35
114
|
|
|
36
115
|
try {
|
|
37
116
|
setIsLoading(true);
|
|
38
117
|
const api = new ProductsApi(AXIOS_CONFIG);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
118
|
+
|
|
119
|
+
// Search for products using getAllProducts
|
|
120
|
+
let response = await api.getAllProducts(
|
|
121
|
+
sanitizedQuery, // searchTerm
|
|
43
122
|
undefined, // maxPrice
|
|
44
123
|
undefined, // minPrice
|
|
45
124
|
undefined, // brandFilter
|
|
46
|
-
|
|
125
|
+
undefined, // availability
|
|
47
126
|
'relevance', // sort
|
|
48
|
-
|
|
127
|
+
undefined, // subCategoryId
|
|
128
|
+
undefined, // categoryId
|
|
49
129
|
true, // isActive
|
|
50
130
|
20, // limit
|
|
51
131
|
1 // page
|
|
52
132
|
);
|
|
53
133
|
|
|
134
|
+
console.log('Search API Response:', {
|
|
135
|
+
query: sanitizedQuery,
|
|
136
|
+
status: response.status,
|
|
137
|
+
dataLength: response.data?.data?.length,
|
|
138
|
+
totalItems: response.data?.totalItems
|
|
139
|
+
});
|
|
140
|
+
|
|
54
141
|
if (response.data?.data) {
|
|
55
|
-
const transformedProducts = response.data.data.map((item:
|
|
142
|
+
const transformedProducts = response.data.data.map((item: Product) => ({
|
|
56
143
|
...item,
|
|
57
144
|
id: item._id || '',
|
|
58
145
|
}));
|
|
59
146
|
|
|
60
147
|
setProducts(transformedProducts);
|
|
148
|
+
|
|
149
|
+
// Save to recent searches if results found
|
|
150
|
+
if (transformedProducts.length > 0) {
|
|
151
|
+
saveRecentSearch(sanitizedQuery);
|
|
152
|
+
}
|
|
61
153
|
} else {
|
|
62
154
|
setProducts([]);
|
|
63
155
|
}
|
|
64
156
|
setHasSearched(true);
|
|
65
157
|
} catch (error) {
|
|
66
158
|
console.error('Error fetching search results:', error);
|
|
159
|
+
if (error instanceof Error) {
|
|
160
|
+
console.error('Error details:', error.message);
|
|
161
|
+
}
|
|
67
162
|
setProducts([]);
|
|
163
|
+
setHasSearched(true);
|
|
68
164
|
} finally {
|
|
69
165
|
setIsLoading(false);
|
|
70
166
|
}
|
|
71
167
|
};
|
|
72
168
|
|
|
73
169
|
fetchSearchResults();
|
|
74
|
-
}, [searchQuery]);
|
|
170
|
+
}, [searchQuery, sanitizeSearchInput, saveRecentSearch]);
|
|
75
171
|
|
|
76
172
|
const handleSearch = (e: React.FormEvent) => {
|
|
77
173
|
e.preventDefault();
|
|
78
|
-
|
|
79
|
-
|
|
174
|
+
const sanitized = sanitizeSearchInput(searchInput);
|
|
175
|
+
if (sanitized) {
|
|
176
|
+
setShowSuggestions(false);
|
|
177
|
+
router.push(buildPath(`/search?q=${encodeURIComponent(sanitized)}`));
|
|
80
178
|
}
|
|
81
179
|
};
|
|
82
180
|
|
|
83
181
|
const clearSearch = () => {
|
|
84
182
|
setSearchInput('');
|
|
183
|
+
setShowSuggestions(false);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const handleSuggestionClick = (suggestion: string) => {
|
|
187
|
+
setSearchInput(suggestion);
|
|
188
|
+
setShowSuggestions(false);
|
|
189
|
+
router.push(buildPath(`/search?q=${encodeURIComponent(suggestion)}`));
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
193
|
+
const value = e.target.value;
|
|
194
|
+
setSearchInput(value);
|
|
195
|
+
setShowSuggestions(value.trim().length >= 2);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const handleInputFocus = () => {
|
|
199
|
+
if (searchInput.trim().length >= 2) {
|
|
200
|
+
setShowSuggestions(true);
|
|
201
|
+
}
|
|
85
202
|
};
|
|
86
203
|
|
|
87
204
|
return (
|
|
@@ -93,9 +210,12 @@ export default function SearchPage() {
|
|
|
93
210
|
<Input
|
|
94
211
|
type="text"
|
|
95
212
|
value={searchInput}
|
|
96
|
-
onChange={
|
|
213
|
+
onChange={handleInputChange}
|
|
214
|
+
onFocus={handleInputFocus}
|
|
215
|
+
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
|
97
216
|
placeholder="Search for products..."
|
|
98
217
|
className="pl-10 pr-10 py-6 text-base rounded-lg border-gray-300 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
218
|
+
autoComplete="off"
|
|
99
219
|
/>
|
|
100
220
|
{searchInput && (
|
|
101
221
|
<button
|
|
@@ -106,10 +226,70 @@ export default function SearchPage() {
|
|
|
106
226
|
<X className="h-5 w-5" />
|
|
107
227
|
</button>
|
|
108
228
|
)}
|
|
229
|
+
|
|
230
|
+
{/* Search Suggestions Dropdown */}
|
|
231
|
+
<AnimatePresence>
|
|
232
|
+
{showSuggestions && (suggestions.length > 0 || recentSearches.length > 0) && (
|
|
233
|
+
<motion.div
|
|
234
|
+
initial={{ opacity: 0, y: -10 }}
|
|
235
|
+
animate={{ opacity: 1, y: 0 }}
|
|
236
|
+
exit={{ opacity: 0, y: -10 }}
|
|
237
|
+
transition={{ duration: 0.2 }}
|
|
238
|
+
className="absolute z-50 w-full mt-2 bg-white rounded-lg shadow-lg border border-gray-200 overflow-hidden"
|
|
239
|
+
>
|
|
240
|
+
{/* Real-time Suggestions */}
|
|
241
|
+
{suggestions.length > 0 && (
|
|
242
|
+
<div className="p-2">
|
|
243
|
+
<div className="px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider flex items-center gap-2">
|
|
244
|
+
<TrendingUp className="h-3 w-3" />
|
|
245
|
+
Suggestions
|
|
246
|
+
</div>
|
|
247
|
+
{suggestions.map((suggestion, index) => (
|
|
248
|
+
<button
|
|
249
|
+
key={index}
|
|
250
|
+
type="button"
|
|
251
|
+
onClick={() => handleSuggestionClick(suggestion)}
|
|
252
|
+
className="w-full text-left px-3 py-2.5 hover:bg-gray-50 rounded-md transition-colors flex items-center gap-2 group"
|
|
253
|
+
>
|
|
254
|
+
<Search className="h-4 w-4 text-gray-400 group-hover:text-primary-500" />
|
|
255
|
+
<span className="text-sm text-gray-700 group-hover:text-gray-900">
|
|
256
|
+
{suggestion}
|
|
257
|
+
</span>
|
|
258
|
+
</button>
|
|
259
|
+
))}
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{/* Recent Searches */}
|
|
264
|
+
{recentSearches.length > 0 && !searchInput.trim() && (
|
|
265
|
+
<div className="p-2 border-t border-gray-100">
|
|
266
|
+
<div className="px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider flex items-center gap-2">
|
|
267
|
+
<Clock className="h-3 w-3" />
|
|
268
|
+
Recent Searches
|
|
269
|
+
</div>
|
|
270
|
+
{recentSearches.map((search, index) => (
|
|
271
|
+
<button
|
|
272
|
+
key={index}
|
|
273
|
+
type="button"
|
|
274
|
+
onClick={() => handleSuggestionClick(search)}
|
|
275
|
+
className="w-full text-left px-3 py-2.5 hover:bg-gray-50 rounded-md transition-colors flex items-center gap-2 group"
|
|
276
|
+
>
|
|
277
|
+
<Clock className="h-4 w-4 text-gray-400 group-hover:text-primary-500" />
|
|
278
|
+
<span className="text-sm text-gray-700 group-hover:text-gray-900">
|
|
279
|
+
{search}
|
|
280
|
+
</span>
|
|
281
|
+
</button>
|
|
282
|
+
))}
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
</motion.div>
|
|
286
|
+
)}
|
|
287
|
+
</AnimatePresence>
|
|
109
288
|
</div>
|
|
110
289
|
<button
|
|
111
290
|
type="submit"
|
|
112
|
-
className="mt-4 w-full bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
|
291
|
+
className="mt-4 w-full bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
292
|
+
disabled={!searchInput.trim()}
|
|
113
293
|
>
|
|
114
294
|
Search
|
|
115
295
|
</button>
|
|
@@ -144,21 +324,79 @@ export default function SearchPage() {
|
|
|
144
324
|
</div>
|
|
145
325
|
) : hasSearched ? (
|
|
146
326
|
<div className="text-center py-12">
|
|
147
|
-
<div className="
|
|
148
|
-
|
|
327
|
+
<div className="mb-6">
|
|
328
|
+
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
329
|
+
<Search className="h-8 w-8 text-gray-400" />
|
|
330
|
+
</div>
|
|
331
|
+
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
|
332
|
+
No products found
|
|
333
|
+
</h3>
|
|
334
|
+
<p className="text-gray-500 text-base mb-1">
|
|
335
|
+
We couldn't find any products matching "{searchQuery}"
|
|
336
|
+
</p>
|
|
337
|
+
<button
|
|
338
|
+
onClick={clearSearch}
|
|
339
|
+
className="px-6 py-2.5 mt-4 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors font-medium border border-gray-300 bg-white cursor-pointer"
|
|
340
|
+
>
|
|
341
|
+
Clear Search
|
|
342
|
+
</button>
|
|
149
343
|
</div>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
344
|
+
|
|
345
|
+
<div className="flex flex-col sm:flex-row gap-3 justify-center items-center">
|
|
346
|
+
|
|
347
|
+
<Link
|
|
348
|
+
href={buildPath('/shop')}
|
|
349
|
+
className="px-6 py-2.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium"
|
|
350
|
+
>
|
|
351
|
+
Browse All Products
|
|
154
352
|
</Link>
|
|
155
|
-
</
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
{recentSearches.length > 0 && (
|
|
356
|
+
<div className="mt-8 pt-8 border-t border-gray-200">
|
|
357
|
+
<p className="text-sm font-medium text-gray-700 mb-3">Recent Searches:</p>
|
|
358
|
+
<div className="flex flex-wrap gap-2 justify-center">
|
|
359
|
+
{recentSearches.map((search, index) => (
|
|
360
|
+
<button
|
|
361
|
+
key={index}
|
|
362
|
+
onClick={() => handleSuggestionClick(search)}
|
|
363
|
+
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-full text-sm transition-colors"
|
|
364
|
+
>
|
|
365
|
+
{search}
|
|
366
|
+
</button>
|
|
367
|
+
))}
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
)}
|
|
156
371
|
</div>
|
|
157
372
|
) : (
|
|
158
373
|
<div className="text-center py-12">
|
|
159
|
-
<
|
|
160
|
-
|
|
374
|
+
<div className="w-16 h-16 bg-primary-50 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
375
|
+
<Search className="h-8 w-8 text-primary-500" />
|
|
376
|
+
</div>
|
|
377
|
+
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
|
378
|
+
Start Searching
|
|
379
|
+
</h3>
|
|
380
|
+
<p className="text-gray-500 mb-6">
|
|
381
|
+
Enter a search term above to find products
|
|
161
382
|
</p>
|
|
383
|
+
{recentSearches.length > 0 && (
|
|
384
|
+
<div className="max-w-md mx-auto">
|
|
385
|
+
<p className="text-sm font-medium text-gray-700 mb-3">Recent Searches:</p>
|
|
386
|
+
<div className="flex flex-wrap gap-2 justify-center">
|
|
387
|
+
{recentSearches.map((search, index) => (
|
|
388
|
+
<button
|
|
389
|
+
key={index}
|
|
390
|
+
onClick={() => handleSuggestionClick(search)}
|
|
391
|
+
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-full text-sm transition-colors flex items-center gap-2"
|
|
392
|
+
>
|
|
393
|
+
<Clock className="h-3 w-3" />
|
|
394
|
+
{search}
|
|
395
|
+
</button>
|
|
396
|
+
))}
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
)}
|
|
162
400
|
</div>
|
|
163
401
|
)}
|
|
164
402
|
</div>
|