PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.6
Yatra – Travel Booking & Tour Operator Software v3.0.6
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.6, at resources/js/components/ui/icon-picker.tsx

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