PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.9
Yatra – Travel Booking & Tour Operator Software v3.0.2.9
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / components / ui / icon-picker.tsx

icon-picker.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.2.9, at resources/js/components/ui/icon-picker.tsx

472 lines 16.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Icon Picker Component
3 * Reusable component for selecting SVG icons or uploading custom images
4 * Supports both Lucide React icons and custom image uploads
5 */
6
7 import React, { useState, useCallback, useEffect } from "react";
8 import {
9 Upload,
10 X,
11 Search,
12 Image as ImageLucide,
13 Sparkles,
14 Check,
15 } from "lucide-react";
16 import { getIconOptions, type IconName } from "../../lib/icons";
17 import { __ } from "../../lib/i18n";
18 import { Button } from "./button";
19 import { Input } from "./input";
20 import { Badge } from "./badge";
21 import { Modal } from "./modal";
22 import { useWordPressMedia } from "../../hooks/useWordPressMedia";
23
24 // Use centralized icon system
25 const iconOptions = getIconOptions();
26
27 const categoryLabels: Record<string, string> = {
28 activity: "Activities",
29 travel: "Travel",
30 food: "Food & Dining",
31 accommodation: "Accommodation",
32 transport: "Transportation",
33 general: "General",
34 };
35
36 export type IconPickerValue = {
37 type: "icon" | "image";
38 value: string; // icon name for type 'icon', attachment ID for type 'image'
39 };
40
41 interface IconPickerProps {
42 value?: IconPickerValue | null;
43 onChange: (value: IconPickerValue | null) => void;
44 label?: string;
45 helpText?: string;
46 error?: string;
47 required?: boolean;
48 allowImageUpload?: boolean;
49 allowIconSelection?: boolean;
50 size?: "sm" | "md" | "lg";
51 className?: string;
52 }
53
54 export const IconPicker: React.FC<IconPickerProps> = ({
55 value,
56 onChange,
57 label,
58 helpText,
59 error,
60 required = false,
61 allowImageUpload = true,
62 allowIconSelection = true,
63 size = "md",
64 className = "",
65 }) => {
66 const [isOpen, setIsOpen] = useState(false);
67 const [activeTab, setActiveTab] = useState<"icons" | "upload">("icons");
68 const [searchTerm, setSearchTerm] = useState("");
69 const [selectedCategory, setSelectedCategory] = useState<string>("all");
70
71 const [imagePreview, setImagePreview] = useState<string | null>(
72 value?.type === "image"
73 ? value.value.startsWith("http")
74 ? value.value
75 : null
76 : null,
77 );
78
79 // Update preview when value changes and fetch URL if it's an attachment ID
80 useEffect(() => {
81 if (value?.type === "image") {
82 // If it's already a URL, use it directly
83 if (
84 value.value.startsWith("http://") ||
85 value.value.startsWith("https://")
86 ) {
87 setImagePreview(value.value);
88 } else if (/^\d+$/.test(value.value) && window.yatraAdmin?.apiUrl) {
89 // It's an attachment ID, fetch the URL from WordPress REST API
90 const apiUrl = window.yatraAdmin.apiUrl.replace("/yatra/v1", "");
91 fetch(`${apiUrl}/wp/v2/media/${value.value}`)
92 .then((res) => res.json())
93 .then((data) => {
94 if (data && data.source_url) {
95 setImagePreview(data.source_url);
96 }
97 })
98 .catch(() => {
99 // Keep null if fetch fails
100 });
101 }
102 } else {
103 setImagePreview(null);
104 }
105 }, [value]);
106
107 // WordPress media library hook
108 const { openMediaLibrary } = useWordPressMedia({
109 title: __("Select or Upload Image", "yatra"),
110 buttonText: __("Use this image", "yatra"),
111 multiple: false,
112 library: { type: "image" },
113 });
114
115 const sizeClasses = {
116 sm: "w-8 h-8",
117 md: "w-12 h-12",
118 lg: "w-16 h-16",
119 };
120
121 const iconSizeClasses = {
122 sm: "w-4 h-4",
123 md: "w-6 h-6",
124 lg: "w-8 h-8",
125 };
126
127 // Filter icons by search and category
128 const filteredIcons = iconOptions.filter((icon) => {
129 const matchesSearch =
130 icon.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
131 icon.name.toLowerCase().includes(searchTerm.toLowerCase());
132 const matchesCategory =
133 selectedCategory === "all" || icon.category === selectedCategory;
134 return matchesSearch && matchesCategory;
135 });
136
137 // Get unique categories
138 const categories = [
139 "all",
140 ...Array.from(new Set(iconOptions.map((icon) => icon.category))),
141 ];
142
143 const handleIconSelect = (iconName: IconName) => {
144 onChange({ type: "icon", value: iconName });
145 setIsOpen(false);
146 setSearchTerm("");
147 };
148
149 const handleWordPressMediaSelect = useCallback(() => {
150 openMediaLibrary((attachment) => {
151 if (attachment && !Array.isArray(attachment)) {
152 // Store attachment ID instead of URL
153 const attachmentId = String(attachment.id);
154 setImagePreview(attachment.url); // Use URL for preview
155 onChange({ type: "image", value: attachmentId });
156 setIsOpen(false);
157 }
158 });
159 }, [openMediaLibrary, onChange]);
160
161 const handleRemoveImage = () => {
162 setImagePreview(null);
163 onChange(null);
164 };
165
166 const handleImageUrlChange = (url: string) => {
167 if (url.trim()) {
168 setImagePreview(url);
169 onChange({ type: "image", value: url.trim() });
170 } else {
171 handleRemoveImage();
172 }
173 };
174
175 const getCurrentIcon = () => {
176 if (!value || value.type !== "icon") return null;
177 const icon = iconOptions.find((opt) => opt.name === value.value);
178 return icon?.component || null;
179 };
180
181 const CurrentIcon = getCurrentIcon();
182
183 return (
184 <div className={`space-y-2 ${className}`}>
185 {label && (
186 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
187 {label} {required && <span className="text-red-500">*</span>}
188 </label>
189 )}
190
191 {helpText && (
192 <p className="text-xs text-gray-500 dark:text-gray-400">{helpText}</p>
193 )}
194
195 {/* Current Selection Display */}
196 <div className="flex items-center gap-3">
197 <div
198 className={`${sizeClasses[size]} rounded-lg border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center bg-gray-50 dark:bg-gray-800 overflow-hidden`}
199 >
200 {value?.type === "image" && imagePreview ? (
201 <img
202 src={imagePreview}
203 alt="Selected"
204 className="w-full h-full object-cover"
205 />
206 ) : value?.type === "icon" && CurrentIcon ? (
207 <CurrentIcon
208 className={`${iconSizeClasses[size]} text-gray-700 dark:text-gray-300`}
209 />
210 ) : (
211 <ImageLucide className={`${iconSizeClasses[size]} text-gray-400`} />
212 )}
213 </div>
214
215 <div className="flex-1">
216 <Button
217 type="button"
218 variant="outline"
219 onClick={() => setIsOpen(!isOpen)}
220 className="w-full justify-start"
221 >
222 {value?.type === "icon"
223 ? `${__("Icon", "yatra")}: ${iconOptions.find((i) => i.name === value.value)?.label || value.value}`
224 : value?.type === "image"
225 ? __("Custom Image", "yatra")
226 : __("Select Icon or Upload Image", "yatra")}
227 </Button>
228 </div>
229
230 {value && (
231 <Button
232 type="button"
233 variant="ghost"
234 size="icon"
235 onClick={() => {
236 onChange(null);
237 setImagePreview(null);
238 }}
239 className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
240 >
241 <X className="w-4 h-4" />
242 </Button>
243 )}
244 </div>
245
246 {error && (
247 <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
248 )}
249
250 {/* Icon Picker Modal */}
251 <Modal
252 isOpen={isOpen}
253 onClose={() => setIsOpen(false)}
254 title={__("Select Icon or Upload Image", "yatra")}
255 size="xl"
256 showCloseButton={true}
257 customZIndex={99999}
258 >
259 {/* Tabs */}
260 <div className="flex border-b border-gray-200 dark:border-gray-700">
261 {allowIconSelection && (
262 <button
263 type="button"
264 onClick={() => setActiveTab("icons")}
265 className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
266 activeTab === "icons"
267 ? "border-b-2 border-blue-600 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20"
268 : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
269 }`}
270 >
271 <Sparkles className="w-4 h-4 inline mr-2" />
272 {__("Icons", "yatra")}
273 </button>
274 )}
275 {allowImageUpload && (
276 <button
277 type="button"
278 onClick={() => setActiveTab("upload")}
279 className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
280 activeTab === "upload"
281 ? "border-b-2 border-blue-600 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20"
282 : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
283 }`}
284 >
285 <Upload className="w-4 h-4 inline mr-2" />
286 {__("Upload Image", "yatra")}
287 </button>
288 )}
289 </div>
290
291 {/* Content */}
292 <div className="p-4 max-h-[60vh] overflow-y-auto">
293 {activeTab === "icons" && allowIconSelection && (
294 <div className="space-y-4">
295 {/* Search */}
296 <div className="relative">
297 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
298 <Input
299 type="text"
300 placeholder={__("Search icons...", "yatra")}
301 value={searchTerm}
302 onChange={(e) => setSearchTerm(e.target.value)}
303 className="pl-9"
304 />
305 </div>
306
307 {/* Category Filter */}
308 <div className="flex flex-wrap gap-2">
309 {categories.map((category) => (
310 <Badge
311 key={category}
312 variant={selectedCategory === category ? "info" : "outline"}
313 className="cursor-pointer"
314 onClick={() => setSelectedCategory(category)}
315 >
316 {category === "all"
317 ? __("All", "yatra")
318 : categoryLabels[category] || category}
319 </Badge>
320 ))}
321 </div>
322
323 {/* Icons Grid */}
324 <div className="grid grid-cols-6 sm:grid-cols-8 md:grid-cols-10 gap-3">
325 {filteredIcons.map((icon) => {
326 const IconComponent = icon.component;
327 const isSelected =
328 value?.type === "icon" && value.value === icon.name;
329 return (
330 <button
331 key={icon.name}
332 type="button"
333 onClick={() => handleIconSelect(icon.name)}
334 className={`relative p-3 rounded-lg border-2 transition-all hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 ${
335 isSelected
336 ? "border-blue-600 bg-blue-50 dark:bg-blue-900/20"
337 : "border-gray-200 dark:border-gray-700"
338 }`}
339 title={icon.label}
340 >
341 <IconComponent className="w-6 h-6 text-gray-700 dark:text-gray-300 mx-auto" />
342 {isSelected && (
343 <div className="absolute top-1 right-1 bg-blue-600 rounded-full p-0.5">
344 <Check className="w-3 h-3 text-white" />
345 </div>
346 )}
347 </button>
348 );
349 })}
350 </div>
351
352 {filteredIcons.length === 0 && (
353 <div className="text-center py-8 text-gray-500 dark:text-gray-400">
354 {__("No icons found", "yatra")}
355 </div>
356 )}
357 </div>
358 )}
359
360 {activeTab === "upload" && allowImageUpload && (
361 <div className="space-y-6 p-2">
362 {/* Image Preview or Upload Area */}
363 {imagePreview ? (
364 <div className="space-y-4">
365 <div className="relative group">
366 <div className="relative overflow-hidden rounded-xl border-2 border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
367 <img
368 src={imagePreview}
369 alt="Preview"
370 className="w-full h-64 object-contain"
371 />
372 <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
373 <Button
374 type="button"
375 variant="outline"
376 size="sm"
377 onClick={handleWordPressMediaSelect}
378 className="bg-white/90 hover:bg-white text-gray-900 border-white"
379 >
380 <Upload className="w-4 h-4 mr-2" />
381 {__("Change Image", "yatra")}
382 </Button>
383 <Button
384 type="button"
385 variant="destructive"
386 size="sm"
387 onClick={handleRemoveImage}
388 className="bg-red-500/90 hover:bg-red-600"
389 >
390 <X className="w-4 h-4 mr-2" />
391 {__("Remove", "yatra")}
392 </Button>
393 </div>
394 </div>
395 </div>
396 </div>
397 ) : (
398 <div className="space-y-4">
399 {/* WordPress Media Library Upload */}
400 <div
401 onClick={handleWordPressMediaSelect}
402 className="group cursor-pointer border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 rounded-xl p-12 text-center transition-all duration-200 hover:bg-blue-50/50 dark:hover:bg-blue-900/10"
403 >
404 <div className="flex flex-col items-center gap-4">
405 <div className="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center group-hover:scale-110 transition-transform">
406 <Upload className="w-8 h-8 text-blue-600 dark:text-blue-400" />
407 </div>
408 <div className="space-y-2">
409 <p className="text-base font-semibold text-gray-900 dark:text-white">
410 {__("Upload Image", "yatra")}
411 </p>
412 <p className="text-sm text-gray-600 dark:text-gray-400">
413 {__("Click to open WordPress Media Library", "yatra")}
414 </p>
415 <p className="text-xs text-gray-500 dark:text-gray-500">
416 {__(
417 "Select from library or upload new image",
418 "yatra",
419 )}
420 </p>
421 </div>
422 </div>
423 </div>
424
425 {/* Divider */}
426 <div className="relative">
427 <div className="absolute inset-0 flex items-center">
428 <div className="w-full border-t border-gray-200 dark:border-gray-700"></div>
429 </div>
430 <div className="relative flex justify-center text-xs uppercase">
431 <span className="bg-white dark:bg-gray-900 px-3 text-gray-500 dark:text-gray-400">
432 {__("Or", "yatra")}
433 </span>
434 </div>
435 </div>
436
437 {/* Image URL Input */}
438 <div className="space-y-3">
439 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
440 {__("Enter Image URL", "yatra")}
441 </label>
442 <Input
443 type="url"
444 placeholder={__("https://example.com/image.png", "yatra")}
445 value={value?.type === "image" ? value.value : ""}
446 onChange={(e) => handleImageUrlChange(e.target.value)}
447 className="h-11"
448 />
449 </div>
450 </div>
451 )}
452 </div>
453 )}
454 </div>
455
456 {/* Footer */}
457 <div className="border-t border-gray-200 dark:border-gray-700 p-4 flex justify-end gap-2">
458 <Button
459 type="button"
460 variant="outline"
461 onClick={() => setIsOpen(false)}
462 >
463 {__("Close", "yatra")}
464 </Button>
465 </div>
466 </Modal>
467 </div>
468 );
469 };
470
471 export default IconPicker;
472