diff --git a/agent/uis/trip-planner/accommodations-list/index.tsx b/agent/uis/trip-planner/accommodations-list/index.tsx index e671e5e..99495b6 100644 --- a/agent/uis/trip-planner/accommodations-list/index.tsx +++ b/agent/uis/trip-planner/accommodations-list/index.tsx @@ -1,195 +1,231 @@ import "./index.css"; +import React, { useEffect } from "react"; +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { TripDetails } from "../../../trip-planner/types"; -import { useState } from "react"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel"; +import { faker } from "@faker-js/faker"; +import { format } from "date-fns"; + +const IMAGE_URLS = [ + "https://a0.muscache.com/im/pictures/c88d4356-9e33-4277-83fd-3053e5695333.jpg?im_w=1200&im_format=avif", + "https://a0.muscache.com/im/pictures/miso/Hosting-999231834211657440/original/fa140513-cc51-48a6-83c9-ef4e11e69bc2.jpeg?im_w=1200&im_format=avif", + "https://a0.muscache.com/im/pictures/miso/Hosting-5264493/original/10d2c21f-84c2-46c5-b20b-b51d1c2c971a.jpeg?im_w=1200&im_format=avif", + "https://a0.muscache.com/im/pictures/d0e3bb05-a96a-45cf-af92-980269168096.jpg?im_w=720&im_format=avif", + "https://a0.muscache.com/im/pictures/miso/Hosting-50597302/original/eb1bb383-4b70-45ae-b3ce-596f83436e6f.jpeg?im_w=720&im_format=avif", + "https://a0.muscache.com/im/pictures/miso/Hosting-900891950206269231/original/7cc71402-9430-48b4-b4f1-e8cac69fd7d3.jpeg?im_w=720&im_format=avif", + "https://a0.muscache.com/im/pictures/460efdcd-1286-431d-b4e5-e316d6427707.jpg?im_w=720&im_format=avif", + "https://a0.muscache.com/im/pictures/prohost-api/Hosting-51234810/original/5231025a-4c39-4a96-ac9c-b088fceb5531.jpeg?im_w=720&im_format=avif", + "https://a0.muscache.com/im/pictures/miso/Hosting-14886949/original/a9d72542-cd1f-418d-b070-a73035f94fe4.jpeg?im_w=720&im_format=avif", + "https://a0.muscache.com/im/pictures/2011683a-c045-4b5a-97a8-37bca4b98079.jpg?im_w=720&im_format=avif", + "https://a0.muscache.com/im/pictures/11bcbeec-749c-4897-8593-1ec6f6dc04ad.jpg?im_w=720&im_format=avif", + "https://a0.muscache.com/im/pictures/prohost-api/Hosting-18327626/original/fba2e4e8-9d68-47a8-838e-dab5353e5209.jpeg?im_w=720&im_format=avif", +]; + +export type Accommodation = { + id: string; + name: string; + price: number; + rating: number; + city: string; + image: string; +}; + +function getAccommodations(city: string): Accommodation[] { + // Shuffle the image URLs array and take the first 6 + const shuffledImages = [...IMAGE_URLS] + .sort(() => Math.random() - 0.5) + .slice(0, 6); + + return Array.from({ length: 6 }, (_, index) => ({ + id: faker.string.uuid(), + name: faker.location.streetAddress(), + price: faker.number.int({ min: 100, max: 1000 }), + rating: Number( + faker.number.float({ min: 4.0, max: 5.0, fractionDigits: 2 }).toFixed(2), + ), + city: city, + image: shuffledImages[index], + })); +} + +const StarSVG = ({ fill = "white" }: { fill?: string }) => ( + + + +); + +function AccommodationCard({ + accommodation, +}: { + accommodation: Accommodation; +}) { + return ( +
+
+

{accommodation.name}

+
+

+ + {accommodation.rating} +

+

·

+

{accommodation.price}

+
+

{accommodation.city}

+
+
+ ); +} + +function SelectedAccommodation({ + accommodation, + onHide, + tripDetails, +}: { + accommodation: Accommodation; + onHide: () => void; + tripDetails: TripDetails; +}) { + const startDate = new Date(tripDetails.startDate); + const endDate = new Date(tripDetails.endDate); + const totalTripDurationDays = Math.max( + startDate.getDate() - endDate.getDate(), + 1, + ); + const totalPrice = totalTripDurationDays * accommodation.price; + + return ( +
+
+ {accommodation.name} +
+
+
+

{accommodation.name}

+ +
+
+
+ + + {accommodation.rating} + +

{accommodation.city}

+
+
+
+ Check-in + {format(startDate, "MMM d, yyyy")} +
+
+ Check-out + {format(endDate, "MMM d, yyyy")} +
+
+ Guests + {tripDetails.numberOfGuests} +
+
+ Total Price + ${totalPrice.toLocaleString()} +
+
+
+ +
+
+ ); +} export default function AccommodationsList({ tripDetails, }: { tripDetails: TripDetails; }) { - // Placeholder data - ideally would come from props - const [accommodations] = useState([ - { - id: "1", - name: "Grand Hotel", - type: "Hotel", - price: "$150/night", - rating: 4.8, - amenities: ["WiFi", "Pool", "Breakfast"], - image: "https://placehold.co/300x200?text=Hotel", - available: true, - }, - { - id: "2", - name: "Cozy Apartment", - type: "Apartment", - price: "$120/night", - rating: 4.5, - amenities: ["WiFi", "Kitchen", "Washing Machine"], - image: "https://placehold.co/300x200?text=Apartment", - available: true, - }, - { - id: "3", - name: "Beachside Villa", - type: "Villa", - price: "$300/night", - rating: 4.9, - amenities: ["WiFi", "Private Pool", "Ocean View"], - image: "https://placehold.co/300x200?text=Villa", - available: false, - }, - ]); + const [places, setPlaces] = React.useState([]); - const [selectedId, setSelectedId] = useState(null); + useEffect(() => { + const accommodations = getAccommodations(tripDetails.location); + setPlaces(accommodations); + }, []); - const selectedAccommodation = accommodations.find( - (acc) => acc.id === selectedId, - ); + const [selectedAccommodation, setSelectedAccommodation] = React.useState< + Accommodation | undefined + >(); + + if (selectedAccommodation) { + return ( + setSelectedAccommodation(undefined)} + accommodation={selectedAccommodation} + /> + ); + } return ( -
-
-
-

- Accommodations in {tripDetails.location} -

- {selectedId && ( - - )} -
-

- {new Date(tripDetails.startDate).toLocaleDateString()} -{" "} - {new Date(tripDetails.endDate).toLocaleDateString()} ·{" "} - {tripDetails.numberOfGuests} guests -

-
- -
- {!selectedId ? ( -
- {accommodations.map((accommodation) => ( -
setSelectedId(accommodation.id)} - className={`flex border rounded-lg p-3 cursor-pointer transition-all ${ - accommodation.available - ? "hover:border-blue-300 hover:shadow-md" - : "opacity-60" - }`} - > -
- {accommodation.name} -
-
-
-

- {accommodation.name} -

- - {accommodation.price} - -
-

{accommodation.type}

-
- - - - - {accommodation.rating} - -
- {!accommodation.available && ( - - Unavailable for your dates - - )} -
-
- ))} -
- ) : ( -
- {selectedAccommodation && ( - <> -
- {selectedAccommodation.name} -
-
-
-

- {selectedAccommodation.name} -

- - {selectedAccommodation.price} - -
-
- - - - - {selectedAccommodation.rating} - -
-

- Perfect accommodation in {tripDetails.location} for your{" "} - {tripDetails.numberOfGuests} guests. -

-
-

- Amenities: -

-
- {selectedAccommodation.amenities.map((amenity) => ( - - {amenity} - - ))} -
-
- -
- - )} -
- )} -
+ + + ))} + + + +
); } diff --git a/package.json b/package.json index e7f3e98..868c037 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@assistant-ui/react": "^0.8.0", "@assistant-ui/react-markdown": "^0.8.0", "@assistant-ui/react-syntax-highlighter": "^0.7.2", + "@faker-js/faker": "^9.5.1", "@langchain/core": "^0.3.41", "@langchain/google-genai": "^0.1.10", "@langchain/langgraph": "^0.2.49", @@ -31,6 +32,8 @@ "@tailwindcss/vite": "^4.0.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.5.2", "esbuild": "^0.25.0", "esbuild-plugin-tailwindcss": "^2.0.1", "framer-motion": "^12.4.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57d55c0..919b32e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: "@assistant-ui/react-syntax-highlighter": specifier: ^0.7.2 version: 0.7.10(@assistant-ui/react-markdown@0.8.0(@assistant-ui/react@0.8.0(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@assistant-ui/react@0.8.0(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react-syntax-highlighter@15.5.13)(@types/react@19.0.10)(react-syntax-highlighter@15.6.1(react@19.0.0))(react@19.0.0) + "@faker-js/faker": + specifier: ^9.5.1 + version: 9.5.1 "@langchain/core": specifier: ^0.3.41 version: 0.3.41(openai@4.85.4(zod@3.24.2)) @@ -69,6 +72,12 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + embla-carousel-react: + specifier: ^8.5.2 + version: 8.5.2(react@19.0.0) esbuild: specifier: ^0.25.0 version: 0.25.0 @@ -921,6 +930,13 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + "@faker-js/faker@9.5.1": + resolution: + { + integrity: sha512-0fzMEDxkExR2cn731kpDaCCnBGBUOIXEi2S1N5l8Hltp6aPf4soTMJ+g4k8r2sI5oB+rpwIW8Uy/6jkwGpnWPg==, + } + engines: { node: ">=18.0.0", npm: ">=9.0.0" } + "@floating-ui/core@1.6.9": resolution: { @@ -2483,6 +2499,12 @@ packages: integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==, } + date-fns@4.1.0: + resolution: + { + integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==, + } + debug@4.4.0: resolution: { @@ -2606,6 +2628,28 @@ packages: integrity: sha512-12keJGdXQWPnfVOu6/6ZzZgPPNodiDOSe3LjA8qk2yXTjnCnw2LeGUsAmtlNAmH4UW0K7tOLcz0j9lI2eJCJRA==, } + embla-carousel-react@8.5.2: + resolution: + { + integrity: sha512-Tmx+uY3MqseIGdwp0ScyUuxpBgx5jX1f7od4Cm5mDwg/dptEiTKf9xp6tw0lZN2VA9JbnVMl/aikmbc53c6QFA==, + } + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.5.2: + resolution: + { + integrity: sha512-QC8/hYSK/pEmqEdU1IO5O+XNc/Ptmmq7uCB44vKplgLKhB/l0+yvYx0+Cv0sF6Ena8Srld5vUErZkT+yTahtDg==, + } + peerDependencies: + embla-carousel: 8.5.2 + + embla-carousel@8.5.2: + resolution: + { + integrity: sha512-xQ9oVLrun/eCG/7ru3R+I5bJ7shsD8fFwLEY7yPe27/+fDHCNj0OT5EoG5ZbFyOxOcG6yTwW8oTz/dWyFnyGpg==, + } + emoji-regex@8.0.0: resolution: { @@ -5854,6 +5898,8 @@ snapshots: "@eslint/core": 0.12.0 levn: 0.4.1 + "@faker-js/faker@9.5.1": {} + "@floating-ui/core@1.6.9": dependencies: "@floating-ui/utils": 0.2.9 @@ -6834,6 +6880,8 @@ snapshots: csstype@3.1.3: {} + date-fns@4.1.0: {} + debug@4.4.0: dependencies: ms: 2.1.3 @@ -6881,6 +6929,18 @@ snapshots: electron-to-chromium@1.5.106: {} + embla-carousel-react@8.5.2(react@19.0.0): + dependencies: + embla-carousel: 8.5.2 + embla-carousel-reactive-utils: 8.5.2(embla-carousel@8.5.2) + react: 19.0.0 + + embla-carousel-reactive-utils@8.5.2(embla-carousel@8.5.2): + dependencies: + embla-carousel: 8.5.2 + + embla-carousel@8.5.2: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx new file mode 100644 index 0000000..09cf122 --- /dev/null +++ b/src/components/ui/carousel.tsx @@ -0,0 +1,239 @@ +import * as React from "react"; +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react"; +import { ArrowLeft, ArrowRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: "horizontal" | "vertical"; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error("useCarousel must be used within a "); + } + + return context; +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins, + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext], + ); + + React.useEffect(() => { + if (!api || !setApi) return; + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) return; + onSelect(api); + api.on("reInit", onSelect); + api.on("select", onSelect); + + return () => { + api?.off("select", onSelect); + }; + }, [api, onSelect]); + + return ( + +
+ {children} +
+
+ ); +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel(); + + return ( +
+ ); +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +};