PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.3
Yatra – Travel Booking & Tour Operator Software v3.0.3
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 2.0.10 All 83 releases
yatra / resources / js / components / ui / icon-picker.tsx

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

583 lines 21.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Icon Picker: Yatra library (icons.json / Lucide SVG) + Font Awesome 6 Free + image upload.
3 */
4
5 import React, { useState, useCallback, useEffect, useMemo } from "react";
6 import {
7 Upload,
8 X,
9 Search,
10 Image as ImageLucide,
11 Sparkles,
12 Check,
13 } from "lucide-react";
14 import { getIconOptions } from "../../lib/icons";
15 import {
16 FA_FREE_SOLID_PICKER,
17 FA_FREE_REGULAR_PICKER,
18 } from "../../lib/fa-free-picker-icons";
19 import type { IconPickerValue, IconProvider } from "../../lib/icon-picker-types";
20
21 export type { IconPickerValue, IconProvider } from "../../lib/icon-picker-types";
22
23 import { __ } from "../../lib/i18n";
24 import { Button } from "./button";
25 import { Input } from "./input";
26 import { Badge } from "./badge";
27 import { Modal } from "./modal";
28 import { useWordPressMedia } from "../../hooks/useWordPressMedia";
29
30 const categoryLabels: Record<string, string> = {
31 activity: "Activities",
32 travel: "Travel",
33 food: "Food & Dining",
34 accommodation: "Accommodation",
35 transport: "Transportation",
36 general: "General",
37 media: "Media",
38 };
39
40 interface IconPickerProps {
41 value?: IconPickerValue | null;
42 onChange: (value: IconPickerValue | null) => void;
43 label?: string;
44 helpText?: string;
45 error?: string;
46 required?: boolean;
47 allowImageUpload?: boolean;
48 allowIconSelection?: boolean;
49 size?: "sm" | "md" | "lg";
50 className?: string;
51 }
52
53 export const IconPicker: React.FC<IconPickerProps> = ({
54 value,
55 onChange,
56 label,
57 helpText,
58 error,
59 required = false,
60 allowImageUpload = true,
61 allowIconSelection = true,
62 size = "md",
63 className = "",
64 }) => {
65 const [isOpen, setIsOpen] = useState(false);
66 const [activeTab, setActiveTab] = useState<"icons" | "upload">("icons");
67 const [iconLibrary, setIconLibrary] = useState<IconProvider>("yatra");
68 const [searchTerm, setSearchTerm] = useState("");
69 const [selectedCategory, setSelectedCategory] = useState<string>("all");
70
71 const yatraIconOptions = useMemo(() => getIconOptions(), []);
72
73 const [imagePreview, setImagePreview] = useState<string | null>(
74 value?.type === "image"
75 ? value.value.startsWith("http")
76 ? value.value
77 : null
78 : null,
79 );
80
81 useEffect(() => {
82 if (value?.type === "image") {
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 const apiUrl = window.yatraAdmin.apiUrl.replace("/yatra/v1", "");
90 fetch(`${apiUrl}/wp/v2/media/${value.value}`)
91 .then((res) => res.json())
92 .then((data) => {
93 if (data && data.source_url) {
94 setImagePreview(data.source_url);
95 }
96 })
97 .catch(() => {});
98 }
99 } else {
100 setImagePreview(null);
101 }
102 }, [value]);
103
104 const { openMediaLibrary } = useWordPressMedia({
105 title: __("Select or Upload Image", "yatra"),
106 buttonText: __("Use this image", "yatra"),
107 multiple: false,
108 library: { type: "image" },
109 });
110
111 const sizeClasses = {
112 sm: "w-8 h-8",
113 md: "w-12 h-12",
114 lg: "w-16 h-16",
115 };
116
117 const iconSizeClasses = {
118 sm: "w-4 h-4",
119 md: "w-6 h-6",
120 lg: "w-8 h-8",
121 };
122
123 const filteredYatraIcons = useMemo(() => {
124 const q = searchTerm.toLowerCase();
125 return yatraIconOptions.filter((icon) => {
126 const matchesSearch =
127 icon.label.toLowerCase().includes(q) ||
128 icon.name.toLowerCase().includes(q);
129 const matchesCategory =
130 selectedCategory === "all" || icon.category === selectedCategory;
131 return matchesSearch && matchesCategory;
132 });
133 }, [yatraIconOptions, searchTerm, selectedCategory]);
134
135 const faSourceList =
136 iconLibrary === "fa-regular" ? FA_FREE_REGULAR_PICKER : FA_FREE_SOLID_PICKER;
137
138 const filteredFaIcons = useMemo(() => {
139 const q = searchTerm.toLowerCase();
140 return faSourceList.filter(
141 (row) =>
142 row.label.toLowerCase().includes(q) || row.name.toLowerCase().includes(q),
143 );
144 }, [faSourceList, searchTerm]);
145
146 const categories = useMemo(
147 () => [
148 "all",
149 ...Array.from(new Set(yatraIconOptions.map((icon) => icon.category))),
150 ],
151 [yatraIconOptions],
152 );
153
154 const selectYatraIcon = (name: string) => {
155 onChange({ type: "icon", value: name, provider: "yatra" });
156 setIsOpen(false);
157 setSearchTerm("");
158 };
159
160 const selectFaIcon = (name: string, style: "fa-solid" | "fa-regular") => {
161 onChange({ type: "icon", value: name, provider: style });
162 setIsOpen(false);
163 setSearchTerm("");
164 };
165
166 const handleWordPressMediaSelect = useCallback(() => {
167 openMediaLibrary((attachment) => {
168 if (attachment && !Array.isArray(attachment)) {
169 const attachmentId = String(attachment.id);
170 setImagePreview(attachment.url);
171 onChange({ type: "image", value: attachmentId });
172 setIsOpen(false);
173 }
174 });
175 }, [openMediaLibrary, onChange]);
176
177 const handleRemoveImage = () => {
178 setImagePreview(null);
179 onChange(null);
180 };
181
182 const handleImageUrlChange = (url: string) => {
183 if (url.trim()) {
184 setImagePreview(url);
185 onChange({ type: "image", value: url.trim() });
186 } else {
187 handleRemoveImage();
188 }
189 };
190
191 const selectionSummary = (): string => {
192 if (!value) {
193 return __("Select Icon or Upload Image", "yatra");
194 }
195 if (value.type === "image") {
196 return __("Custom Image", "yatra");
197 }
198 const p = value.provider ?? "yatra";
199 if (p === "fa-solid") {
200 return `${__("Font Awesome Solid", "yatra")}: ${value.value}`;
201 }
202 if (p === "fa-regular") {
203 return `${__("Font Awesome Regular", "yatra")}: ${value.value}`;
204 }
205 const meta = yatraIconOptions.find((o) => o.name === value.value);
206 return `${__("Yatra", "yatra")}: ${meta?.label || value.value}`;
207 };
208
209 const renderIconPreview = () => {
210 if (!value || value.type !== "icon") {
211 return <ImageLucide className={`${iconSizeClasses[size]} text-gray-400`} />;
212 }
213 const p = value.provider ?? "yatra";
214 if (p === "fa-solid" || p === "fa-regular") {
215 const prefix = p === "fa-regular" ? "fa-regular" : "fa-solid";
216 return (
217 <i
218 className={`${prefix} fa-${value.value} ${iconSizeClasses[size]} text-gray-700 dark:text-gray-300 inline-flex items-center justify-center`}
219 aria-hidden="true"
220 />
221 );
222 }
223 const opt = yatraIconOptions.find((o) => o.name === value.value);
224 if (opt?.svg) {
225 return (
226 <span
227 className={`inline-flex items-center justify-center ${iconSizeClasses[size]} text-gray-700 dark:text-gray-300 [&>svg]:w-full [&>svg]:h-full`}
228 // eslint-disable-next-line react/no-danger -- bundled Yatra icons.json SVG
229 dangerouslySetInnerHTML={{ __html: opt.svg }}
230 />
231 );
232 }
233 return <ImageLucide className={`${iconSizeClasses[size]} text-gray-400`} />;
234 };
235
236 return (
237 <div className={`space-y-2 ${className}`}>
238 {label && (
239 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
240 {label} {required && <span className="text-red-500">*</span>}
241 </label>
242 )}
243
244 {helpText && (
245 <p className="text-xs text-gray-500 dark:text-gray-400">{helpText}</p>
246 )}
247
248 <div className="flex items-center gap-3">
249 <div
250 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`}
251 >
252 {value?.type === "image" && imagePreview ? (
253 <img
254 src={imagePreview}
255 alt="Selected"
256 className="w-full h-full object-cover"
257 />
258 ) : (
259 renderIconPreview()
260 )}
261 </div>
262
263 <div className="flex-1">
264 <Button
265 type="button"
266 variant="outline"
267 onClick={() => setIsOpen(!isOpen)}
268 className="w-full justify-start"
269 >
270 {selectionSummary()}
271 </Button>
272 </div>
273
274 {value && (
275 <Button
276 type="button"
277 variant="ghost"
278 size="icon"
279 onClick={() => {
280 onChange(null);
281 setImagePreview(null);
282 }}
283 className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
284 >
285 <X className="w-4 h-4" />
286 </Button>
287 )}
288 </div>
289
290 {error && (
291 <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
292 )}
293
294 <Modal
295 isOpen={isOpen}
296 onClose={() => setIsOpen(false)}
297 title={__("Select Icon or Upload Image", "yatra")}
298 size="xl"
299 showCloseButton={true}
300 customZIndex={99999}
301 >
302 <div className="flex border-b border-gray-200 dark:border-gray-700">
303 {allowIconSelection && (
304 <button
305 type="button"
306 onClick={() => setActiveTab("icons")}
307 className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
308 activeTab === "icons"
309 ? "border-b-2 border-blue-600 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20"
310 : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
311 }`}
312 >
313 <Sparkles className="w-4 h-4 inline mr-2" />
314 {__("Icons", "yatra")}
315 </button>
316 )}
317 {allowImageUpload && (
318 <button
319 type="button"
320 onClick={() => setActiveTab("upload")}
321 className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
322 activeTab === "upload"
323 ? "border-b-2 border-blue-600 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20"
324 : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
325 }`}
326 >
327 <Upload className="w-4 h-4 inline mr-2" />
328 {__("Upload Image", "yatra")}
329 </button>
330 )}
331 </div>
332
333 <div className="p-4 max-h-[60vh] overflow-y-auto">
334 {activeTab === "icons" && allowIconSelection && (
335 <div className="space-y-4">
336 <div className="flex flex-wrap gap-2 border-b border-gray-100 dark:border-gray-800 pb-3">
337 <Badge
338 variant={iconLibrary === "yatra" ? "info" : "outline"}
339 className="cursor-pointer"
340 onClick={() => setIconLibrary("yatra")}
341 >
342 {__("Yatra library", "yatra")}
343 </Badge>
344 <Badge
345 variant={iconLibrary === "fa-solid" ? "info" : "outline"}
346 className="cursor-pointer"
347 onClick={() => setIconLibrary("fa-solid")}
348 >
349 {__("Font Awesome Solid", "yatra")}
350 </Badge>
351 <Badge
352 variant={iconLibrary === "fa-regular" ? "info" : "outline"}
353 className="cursor-pointer"
354 onClick={() => setIconLibrary("fa-regular")}
355 >
356 {__("Font Awesome Regular", "yatra")}
357 </Badge>
358 </div>
359
360 <div className="relative">
361 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
362 <Input
363 type="text"
364 placeholder={__("Search icons...", "yatra")}
365 value={searchTerm}
366 onChange={(e) => setSearchTerm(e.target.value)}
367 className="pl-9"
368 />
369 </div>
370
371 {(iconLibrary === "fa-solid" || iconLibrary === "fa-regular") && (
372 <p className="text-xs text-gray-500 dark:text-gray-400">
373 {__(
374 "Tip: use search to filter the full Font Awesome Free library (1000+ solid, 150+ regular).",
375 "yatra",
376 )}
377 </p>
378 )}
379
380 {iconLibrary === "yatra" && (
381 <div className="flex flex-wrap gap-2">
382 {categories.map((category) => (
383 <Badge
384 key={category}
385 variant={
386 selectedCategory === category ? "info" : "outline"
387 }
388 className="cursor-pointer"
389 onClick={() => setSelectedCategory(category)}
390 >
391 {category === "all"
392 ? __("All", "yatra")
393 : categoryLabels[category] || category}
394 </Badge>
395 ))}
396 </div>
397 )}
398
399 {iconLibrary === "yatra" && (
400 <div className="grid grid-cols-6 sm:grid-cols-8 md:grid-cols-10 gap-3">
401 {filteredYatraIcons.map((icon) => {
402 const isSelected =
403 value?.type === "icon" &&
404 (value.provider ?? "yatra") === "yatra" &&
405 value.value === icon.name;
406 return (
407 <button
408 key={icon.name}
409 type="button"
410 onClick={() => selectYatraIcon(icon.name)}
411 className={`relative p-3 rounded-lg border-2 transition-all hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 ${
412 isSelected
413 ? "border-blue-600 bg-blue-50 dark:bg-blue-900/20"
414 : "border-gray-200 dark:border-gray-700"
415 }`}
416 title={icon.label}
417 >
418 <span
419 className="w-6 h-6 mx-auto text-gray-700 dark:text-gray-300 flex items-center justify-center [&>svg]:w-6 [&>svg]:h-6"
420 // eslint-disable-next-line react/no-danger
421 dangerouslySetInnerHTML={{ __html: icon.svg }}
422 />
423 {isSelected && (
424 <div className="absolute top-1 right-1 bg-blue-600 rounded-full p-0.5">
425 <Check className="w-3 h-3 text-white" />
426 </div>
427 )}
428 </button>
429 );
430 })}
431 </div>
432 )}
433
434 {(iconLibrary === "fa-solid" || iconLibrary === "fa-regular") && (
435 <div className="grid grid-cols-6 sm:grid-cols-8 md:grid-cols-10 gap-3">
436 {filteredFaIcons.map((row) => {
437 const isSelected =
438 value?.type === "icon" &&
439 value.provider === row.style &&
440 value.value === row.name;
441 return (
442 <button
443 key={`${row.style}-${row.name}`}
444 type="button"
445 onClick={() => selectFaIcon(row.name, row.style)}
446 className={`relative p-3 rounded-lg border-2 transition-all hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 ${
447 isSelected
448 ? "border-blue-600 bg-blue-50 dark:bg-blue-900/20"
449 : "border-gray-200 dark:border-gray-700"
450 }`}
451 title={row.label}
452 >
453 <i
454 className={`${row.style} fa-${row.name} w-6 h-6 mx-auto text-gray-700 dark:text-gray-300 inline-flex items-center justify-center`}
455 aria-hidden="true"
456 />
457 {isSelected && (
458 <div className="absolute top-1 right-1 bg-blue-600 rounded-full p-0.5">
459 <Check className="w-3 h-3 text-white" />
460 </div>
461 )}
462 </button>
463 );
464 })}
465 </div>
466 )}
467
468 {iconLibrary === "yatra" && filteredYatraIcons.length === 0 && (
469 <div className="text-center py-8 text-gray-500 dark:text-gray-400">
470 {__("No icons found", "yatra")}
471 </div>
472 )}
473 {(iconLibrary === "fa-solid" || iconLibrary === "fa-regular") &&
474 filteredFaIcons.length === 0 && (
475 <div className="text-center py-8 text-gray-500 dark:text-gray-400">
476 {__("No icons found", "yatra")}
477 </div>
478 )}
479 </div>
480 )}
481
482 {activeTab === "upload" && allowImageUpload && (
483 <div className="space-y-6 p-2">
484 {imagePreview ? (
485 <div className="space-y-4">
486 <div className="relative group">
487 <div className="relative overflow-hidden rounded-xl border-2 border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
488 <img
489 src={imagePreview}
490 alt="Preview"
491 className="w-full h-64 object-contain"
492 />
493 <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
494 <Button
495 type="button"
496 variant="outline"
497 size="sm"
498 onClick={handleWordPressMediaSelect}
499 className="bg-white/90 hover:bg-white text-gray-900 border-white"
500 >
501 <Upload className="w-4 h-4 mr-2" />
502 {__("Change Image", "yatra")}
503 </Button>
504 <Button
505 type="button"
506 variant="destructive"
507 size="sm"
508 onClick={handleRemoveImage}
509 className="bg-red-500/90 hover:bg-red-600"
510 >
511 <X className="w-4 h-4 mr-2" />
512 {__("Remove", "yatra")}
513 </Button>
514 </div>
515 </div>
516 </div>
517 </div>
518 ) : (
519 <div className="space-y-4">
520 <div
521 onClick={handleWordPressMediaSelect}
522 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"
523 >
524 <div className="flex flex-col items-center gap-4">
525 <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">
526 <Upload className="w-8 h-8 text-blue-600 dark:text-blue-400" />
527 </div>
528 <div className="space-y-2">
529 <p className="text-base font-semibold text-gray-900 dark:text-white">
530 {__("Upload Image", "yatra")}
531 </p>
532 <p className="text-sm text-gray-600 dark:text-gray-400">
533 {__("Click to open WordPress Media Library", "yatra")}
534 </p>
535 </div>
536 </div>
537 </div>
538
539 <div className="relative">
540 <div className="absolute inset-0 flex items-center">
541 <div className="w-full border-t border-gray-200 dark:border-gray-700"></div>
542 </div>
543 <div className="relative flex justify-center text-xs uppercase">
544 <span className="bg-white dark:bg-gray-900 px-3 text-gray-500 dark:text-gray-400">
545 {__("Or", "yatra")}
546 </span>
547 </div>
548 </div>
549
550 <div className="space-y-3">
551 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
552 {__("Enter Image URL", "yatra")}
553 </label>
554 <Input
555 type="url"
556 placeholder={__("https://example.com/image.png", "yatra")}
557 value={value?.type === "image" ? value.value : ""}
558 onChange={(e) => handleImageUrlChange(e.target.value)}
559 className="h-11"
560 />
561 </div>
562 </div>
563 )}
564 </div>
565 )}
566 </div>
567
568 <div className="border-t border-gray-200 dark:border-gray-700 p-4 flex justify-end gap-2">
569 <Button
570 type="button"
571 variant="outline"
572 onClick={() => setIsOpen(false)}
573 >
574 {__("Close", "yatra")}
575 </Button>
576 </div>
577 </Modal>
578 </div>
579 );
580 };
581
582 export default IconPicker;
583