| @@ -21,9 +21,12 @@ | ||
| 21 | 21 | Search, |
| 22 | 22 | X, |
| 23 | 23 | ArrowUpDown, |
| 24 | 24 | RefreshCw, |
| 25 | + Sparkles, | |
| 25 | 26 | } from "lucide-react"; |
| 27 | +import { CreateTripWithAiWizard } from "../components/ai/CreateTripWithAiWizard"; | |
| 28 | +import { isAiEligible, isAiModuleEnabled } from "../lib/ai-availability"; | |
| 26 | 29 | import { __ } from "../lib/i18n"; |
| 27 | 30 | import { usePermissions } from "../hooks/usePermissions"; |
| 28 | 31 | import { Button } from "../components/ui/button"; |
| 29 | 32 | import { Input } from "../components/ui/input"; |
| @@ -42,9 +45,9 @@ | ||
| 42 | 45 | isWordPressPlainPermalink, |
| 43 | 46 | } from "../lib/frontend-permalink-urls"; |
| 44 | 47 | import { useToast } from "../components/ui/toast"; |
| 45 | 48 | import { generateSlug } from "../lib/slug"; |
| 46 | -import { getCurrencySymbol, getCurrency } from "../data/currencies"; | |
| 49 | +import { formatYatraMoney } from "../lib/currency-display"; | |
| 47 | 50 | import { Pagination } from "../components/shared/Pagination"; |
| 48 | 51 | import { Table as SharedTable } from "../components/shared/Table"; |
| 49 | 52 | import { BulkActionToolbar } from "../components/shared/BulkActionToolbar"; |
| 50 | 53 | import { ConfirmationDialog } from "../components/ui/confirmation-dialog"; |
| @@ -123,8 +126,9 @@ | ||
| 123 | 126 | const [sortBy, setSortBy] = useState("id"); |
| 124 | 127 | const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); |
| 125 | 128 | const [page, setPage] = useState(1); |
| 126 | 129 | const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); |
| 130 | + const [isAiWizardOpen, setIsAiWizardOpen] = useState(false); | |
| 127 | 131 | const [newTripTitle, setNewTripTitle] = useState(""); |
| 128 | 132 | const [createTripError, setCreateTripError] = useState<string | null>(null); |
| 129 | 133 | const [newTripSlug, setNewTripSlug] = useState(""); |
| 130 | 134 | const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false); |
| @@ -363,9 +367,9 @@ | ||
| 363 | 367 | setIsSlugManuallyEdited(true); |
| 364 | 368 | } |
| 365 | 369 | const sanitized = value |
| 366 | 370 | .toLowerCase() |
| 367 | - .replace(/[^a-z0-9-]/g, "-") | |
| 371 | + .replace(/[^\p{L}\p{N}-]/gu, "-") | |
| 368 | 372 | .replace(/-+/g, "-") |
| 369 | 373 | .replace(/^-|-$/g, ""); |
| 370 | 374 | setNewTripSlug(sanitized); |
| 371 | 375 | }; |
| @@ -388,18 +392,9 @@ | ||
| 388 | 392 | }; |
| 389 | 393 | |
| 390 | 394 | const formatPrice = (trip: Trip) => { |
| 391 | 395 | const currencyCode = defaultCurrency; |
| 392 | - const symbol = getCurrencySymbol(currencyCode); | |
| 393 | - const currencyData = getCurrency(currencyCode); | |
| 394 | - const decimals = currencyData?.decimalDigits ?? 2; | |
| 395 | 396 | |
| 396 | - const formatNumber = (value: number) => | |
| 397 | - new Intl.NumberFormat(undefined, { | |
| 398 | - minimumFractionDigits: decimals, | |
| 399 | - maximumFractionDigits: decimals, | |
| 400 | - }).format(value); | |
| 401 | - | |
| 402 | 397 | // Traveler-based pricing: show min - max range |
| 403 | 398 | if (trip.pricing_type === "traveler_based") { |
| 404 | 399 | let minPrice: number | null = null; |
| 405 | 400 | let maxPrice: number | null = null; |
| @@ -433,11 +428,11 @@ | ||
| 433 | 428 | } |
| 434 | 429 | |
| 435 | 430 | if (minPrice !== null && maxPrice !== null) { |
| 436 | 431 | if (minPrice === maxPrice) { |
| 437 | - return `${symbol}${formatNumber(minPrice)} \n`; | |
| 432 | + return `${formatYatraMoney(minPrice, currencyCode, { zeroAsUnknown: false })} \n`; | |
| 438 | 433 | } |
| 439 | - return `${symbol}${formatNumber(minPrice)} - ${symbol}${formatNumber(maxPrice)}`; | |
| 434 | + return `${formatYatraMoney(minPrice, currencyCode, { zeroAsUnknown: false })} - ${formatYatraMoney(maxPrice, currencyCode, { zeroAsUnknown: false })}`; | |
| 440 | 435 | } |
| 441 | 436 | } |
| 442 | 437 | |
| 443 | 438 | // Fallback / regular pricing: use sale_price, then discounted_price, then original_price |
| @@ -447,9 +442,11 @@ | ||
| 447 | 442 | if (!price) { |
| 448 | 443 | return "-"; |
| 449 | 444 | } |
| 450 | 445 | |
| 451 | - return `${symbol}${formatNumber(price)}`; | |
| 446 | + return formatYatraMoney(Number(price), currencyCode, { | |
| 447 | + zeroAsUnknown: false, | |
| 448 | + }); | |
| 452 | 449 | }; |
| 453 | 450 | |
| 454 | 451 | const getStatusBadge = (status: string) => { |
| 455 | 452 | const statusMap: Record<string, { className: string; label: string }> = { |
| @@ -507,14 +504,14 @@ | ||
| 507 | 504 | |
| 508 | 505 | const typeMap: Record<string, { className: string; label: string }> = { |
| 509 | 506 | single_day: { |
| 510 | 507 | className: |
| 511 | - "bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-400", | |
| 508 | + "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400", | |
| 512 | 509 | label: __("Single Day", "yatra"), |
| 513 | 510 | }, |
| 514 | 511 | multi_day: { |
| 515 | 512 | className: |
| 516 | - "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-400", | |
| 513 | + "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/20 dark:text-indigo-400", | |
| 517 | 514 | label: __("Multi-Day", "yatra"), |
| 518 | 515 | }, |
| 519 | 516 | flexible: { |
| 520 | 517 | className: |
| @@ -555,9 +552,9 @@ | ||
| 555 | 552 | } catch (error) { |
| 556 | 553 | return null; |
| 557 | 554 | } |
| 558 | 555 | }, |
| 559 | - enabled: can("manage_yatra"), | |
| 556 | + enabled: can("yatra_access_admin"), | |
| 560 | 557 | staleTime: 5 * 60 * 1000, // Cache for 5 minutes |
| 561 | 558 | }); |
| 562 | 559 | |
| 563 | 560 | // Global/default currency (fallbacks: window.yatraAdmin, settings, then USD) |
| @@ -627,14 +624,11 @@ | ||
| 627 | 624 | if (!slug) { |
| 628 | 625 | setCreateTripError(__("Trip slug is required", "yatra")); |
| 629 | 626 | return; |
| 630 | 627 | } |
| 631 | - if (!/^[a-z0-9-]+$/.test(slug)) { | |
| 628 | + if (!/^[\p{L}\p{N}-]+$/u.test(slug)) { | |
| 632 | 629 | setCreateTripError( |
| 633 | - __( | |
| 634 | - "Slug can only contain lowercase letters, numbers, and hyphens", | |
| 635 | - "yatra", | |
| 636 | - ), | |
| 630 | + __("Slug can only contain letters, numbers, and hyphens", "yatra"), | |
| 637 | 631 | ); |
| 638 | 632 | return; |
| 639 | 633 | } |
| 640 | 634 | setNewTripSlug(slug); |
| @@ -1253,9 +1247,9 @@ | ||
| 1253 | 1247 | </span> |
| 1254 | 1248 | ); |
| 1255 | 1249 | } |
| 1256 | 1250 | return ( |
| 1257 | - <Badge className="text-xs bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-400"> | |
| 1251 | + <Badge className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"> | |
| 1258 | 1252 | {attrCount} {__("attributes", "yatra")} |
| 1259 | 1253 | </Badge> |
| 1260 | 1254 | ); |
| 1261 | 1255 | }, |
| @@ -1303,8 +1297,9 @@ | ||
| 1303 | 1297 | }, |
| 1304 | 1298 | }); |
| 1305 | 1299 | |
| 1306 | 1300 | return cols; |
| 1301 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 1307 | 1302 | }, [ |
| 1308 | 1303 | isPro, |
| 1309 | 1304 | visibleColumns, |
| 1310 | 1305 | summarizeDestinations, |
| @@ -1466,8 +1461,9 @@ | ||
| 1466 | 1461 | }); |
| 1467 | 1462 | } |
| 1468 | 1463 | |
| 1469 | 1464 | return actions; |
| 1465 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 1470 | 1466 | }, [can, queryClient, showToast]); |
| 1471 | 1467 | |
| 1472 | 1468 | return ( |
| 1473 | 1469 | <> |
| @@ -1492,14 +1488,28 @@ | ||
| 1492 | 1488 | > |
| 1493 | 1489 | {__("Refresh Data", "yatra")} |
| 1494 | 1490 | </Button> |
| 1495 | 1491 | )} |
| 1492 | + {isAiEligible() && isAiModuleEnabled() && ( | |
| 1493 | + <Button | |
| 1494 | + type="button" | |
| 1495 | + onClick={() => setIsAiWizardOpen(true)} | |
| 1496 | + className="inline-flex items-center gap-2 whitespace-nowrap bg-gradient-to-r from-blue-600 to-blue-700 text-white border-0 hover:from-blue-700 hover:to-blue-800" | |
| 1497 | + title={__( | |
| 1498 | + "Have AI draft an entire trip — details, itinerary, SEO — from a few setup questions.", | |
| 1499 | + "yatra", | |
| 1500 | + )} | |
| 1501 | + > | |
| 1502 | + <Sparkles className="h-4 w-4 shrink-0" /> | |
| 1503 | + <span>{__("Add Trip with AI", "yatra")}</span> | |
| 1504 | + </Button> | |
| 1505 | + )} | |
| 1496 | 1506 | <Button |
| 1497 | 1507 | onClick={handleCreateTrip} |
| 1498 | - className="flex items-center gap-2" | |
| 1508 | + className="inline-flex items-center gap-2 whitespace-nowrap" | |
| 1499 | 1509 | > |
| 1500 | - <Plus className="w-4 h-4" /> | |
| 1501 | - {__("Add New Trip", "yatra")} | |
| 1510 | + <Plus className="h-4 w-4 shrink-0" /> | |
| 1511 | + <span>{__("Add New Trip", "yatra")}</span> | |
| 1502 | 1512 | </Button> |
| 1503 | 1513 | </div> |
| 1504 | 1514 | } |
| 1505 | 1515 | /> |
| @@ -1817,8 +1827,15 @@ | ||
| 1817 | 1827 | </div> |
| 1818 | 1828 | )} |
| 1819 | 1829 | </div> |
| 1820 | 1830 | </Modal> |
| 1831 | + | |
| 1832 | + {/* AI: "Add Trip with AI" wizard — multi-step setup → generate → | |
| 1833 | + review → save. Drafts an entire trip from a few seed questions. */} | |
| 1834 | + <CreateTripWithAiWizard | |
| 1835 | + open={isAiWizardOpen} | |
| 1836 | + onClose={() => setIsAiWizardOpen(false)} | |
| 1837 | + /> | |
| 1821 | 1838 | |
| 1822 | 1839 | {/* Permanent Delete Confirmation (for Trash only) */} |
| 1823 | 1840 | <ConfirmationDialog |
| 1824 | 1841 | isOpen={isDeleteDialogOpen && !!tripToDelete} |