diff --git a/agent/agent.tsx b/agent/agent.tsx index bb7b667..4e8718c 100644 --- a/agent/agent.tsx +++ b/agent/agent.tsx @@ -4,18 +4,22 @@ import { z } from "zod"; import { GenerativeUIAnnotation, GenerativeUIState } from "./types"; import { stockbrokerGraph } from "./stockbroker"; import { ChatOpenAI } from "@langchain/openai"; +import { tripPlannerGraph } from "./trip-planner"; +import { formatMessages } from "./utils/format-messages"; + +const allToolDescriptions = `- stockbroker: can fetch the price of a ticker, purchase/sell a ticker, or get the user's portfolio +- tripPlanner: helps the user plan their trip. it can suggest restaurants, and places to stay in any given location.`; async function router( state: GenerativeUIState, ): Promise> { const routerDescription = `The route to take based on the user's input. -- stockbroker: can fetch the price of a ticker, purchase/sell a ticker, or get the user's portfolio -- weather: can fetch the current weather conditions for a location +${allToolDescriptions} - generalInput: handles all other cases where the above tools don't apply `; const routerSchema = z.object({ route: z - .enum(["stockbroker", "weather", "generalInput"]) + .enum(["stockbroker", "tripPlanner", "generalInput"]) .describe(routerDescription), }); const routerTool = { @@ -34,17 +38,25 @@ async function router( const prompt = `You're a highly helpful AI assistant, tasked with routing the user's query to the appropriate tool. You should analyze the user's input, and choose the appropriate tool to use.`; - const recentHumanMessage = state.messages.findLast( - (m) => m.getType() === "human", - ); + const allMessagesButLast = state.messages.slice(0, -1); + const lastMessage = state.messages.at(-1); - if (!recentHumanMessage) { - throw new Error("No human message found in state"); - } + const formattedPreviousMessages = formatMessages(allMessagesButLast); + const formattedLastMessage = lastMessage ? formatMessages([lastMessage]) : ""; + + const humanMessage = `Here is the full conversation, excluding the most recent message: + +${formattedPreviousMessages} + +Here is the most recent message: + +${formattedLastMessage} + +Please pick the proper route based on the most recent message, in the context of the entire conversation.`; const response = await llm.invoke([ { role: "system", content: prompt }, - recentHumanMessage, + { role: "user", content: humanMessage }, ]); const toolCall = response.tool_calls?.[0]?.args as @@ -61,13 +73,19 @@ You should analyze the user's input, and choose the appropriate tool to use.`; function handleRoute( state: GenerativeUIState, -): "stockbroker" | "weather" | "generalInput" { +): "stockbroker" | "tripPlanner" | "generalInput" { return state.next; } async function handleGeneralInput(state: GenerativeUIState) { const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 }); - const response = await llm.invoke(state.messages); + const response = await llm.invoke([ + { + role: "system", + content: `You are an AI assistant.\nIf the user asks what you can do, describe these tools. Otherwise, just answer as normal.\n\n${allToolDescriptions}`, + }, + ...state.messages, + ]); return { messages: [response], @@ -77,19 +95,17 @@ async function handleGeneralInput(state: GenerativeUIState) { const builder = new StateGraph(GenerativeUIAnnotation) .addNode("router", router) .addNode("stockbroker", stockbrokerGraph) - .addNode("weather", () => { - throw new Error("Weather not implemented"); - }) + .addNode("tripPlanner", tripPlannerGraph) .addNode("generalInput", handleGeneralInput) .addConditionalEdges("router", handleRoute, [ "stockbroker", - "weather", + "tripPlanner", "generalInput", ]) .addEdge(START, "router") .addEdge("stockbroker", END) - .addEdge("weather", END) + .addEdge("tripPlanner", END) .addEdge("generalInput", END); export const graph = builder.compile(); diff --git a/agent/stockbroker/types.ts b/agent/stockbroker/types.ts index bd32d2e..ebe3fdb 100644 --- a/agent/stockbroker/types.ts +++ b/agent/stockbroker/types.ts @@ -5,7 +5,6 @@ export const StockbrokerAnnotation = Annotation.Root({ messages: GenerativeUIAnnotation.spec.messages, ui: GenerativeUIAnnotation.spec.ui, timestamp: GenerativeUIAnnotation.spec.timestamp, - next: Annotation<"stockbroker" | "weather">(), }); export type StockbrokerState = typeof StockbrokerAnnotation.State; diff --git a/agent/trip-planner/index.tsx b/agent/trip-planner/index.tsx new file mode 100644 index 0000000..60f8f25 --- /dev/null +++ b/agent/trip-planner/index.tsx @@ -0,0 +1,51 @@ +import { StateGraph, START, END } from "@langchain/langgraph"; +import { TripPlannerAnnotation, TripPlannerState } from "./types"; +import { extraction } from "./nodes/extraction"; +import { callTools } from "./nodes/tools"; +import { classify } from "./nodes/classify"; + +function routeStart(state: TripPlannerState): "classify" | "extraction" { + if (!state.tripDetails) { + return "extraction"; + } + + return "classify"; +} + +function routeAfterClassifying( + state: TripPlannerState, +): "callTools" | "extraction" { + // if `tripDetails` is undefined, this means they are not relevant to the conversation + if (!state.tripDetails) { + return "extraction"; + } + + // otherwise, they are relevant, and we should route to callTools + return "callTools"; +} + +function routeAfterExtraction( + state: TripPlannerState, +): "callTools" | typeof END { + // if `tripDetails` is undefined, this means they're missing some fields. + if (!state.tripDetails) { + return END; + } + + return "callTools"; +} + +const builder = new StateGraph(TripPlannerAnnotation) + .addNode("classify", classify) + .addNode("extraction", extraction) + .addNode("callTools", callTools) + .addConditionalEdges(START, routeStart, ["classify", "extraction"]) + .addConditionalEdges("classify", routeAfterClassifying, [ + "callTools", + "extraction", + ]) + .addConditionalEdges("extraction", routeAfterExtraction, ["callTools", END]) + .addEdge("callTools", END); + +export const tripPlannerGraph = builder.compile(); +tripPlannerGraph.name = "Trip Planner"; diff --git a/agent/trip-planner/nodes/classify.ts b/agent/trip-planner/nodes/classify.ts new file mode 100644 index 0000000..945c056 --- /dev/null +++ b/agent/trip-planner/nodes/classify.ts @@ -0,0 +1,70 @@ +import { ChatOpenAI } from "@langchain/openai"; +import { TripPlannerState } from "../types"; +import { z } from "zod"; +import { formatMessages } from "agent/utils/format-messages"; + +export async function classify( + state: TripPlannerState, +): Promise> { + if (!state.tripDetails) { + // Can not classify if tripDetails are undefined + return {}; + } + + const schema = z.object({ + isRelevant: z + .boolean() + .describe( + "Whether the trip details are still relevant to the user's request.", + ), + }); + + const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools( + [ + { + name: "classify", + description: + "A tool to classify whether or not the trip details are still relevant to the user's request.", + schema, + }, + ], + { + tool_choice: "classify", + }, + ); + + const prompt = `You're an AI assistant for planning trips. The user has already specified the following details for their trip: +- location - ${state.tripDetails.location} +- startDate - ${state.tripDetails.startDate} +- endDate - ${state.tripDetails.endDate} +- numberOfGuests - ${state.tripDetails.numberOfGuests} + +Your task is to carefully read over the user's conversation, and determine if their trip details are still relevant to their most recent request. +You should set is relevant to false if they are now asking about a new location, trip duration, or number of guests. +If they do NOT change their request details (or they never specified them), please set is relevant to true. +`; + + const humanMessage = `Here is the entire conversation so far:\n${formatMessages(state.messages)}`; + + const response = await model.invoke([ + { role: "system", content: prompt }, + { role: "human", content: humanMessage }, + ]); + + const classificationDetails = response.tool_calls?.[0]?.args as + | z.infer + | undefined; + + if (!classificationDetails) { + throw new Error("Could not classify trip details"); + } + + if (!classificationDetails.isRelevant) { + return { + tripDetails: undefined, + }; + } + + // If it is relevant, return the state unchanged + return {}; +} diff --git a/agent/trip-planner/nodes/extraction.tsx b/agent/trip-planner/nodes/extraction.tsx new file mode 100644 index 0000000..264e992 --- /dev/null +++ b/agent/trip-planner/nodes/extraction.tsx @@ -0,0 +1,123 @@ +import { ChatOpenAI } from "@langchain/openai"; +import { TripDetails, TripPlannerState } from "../types"; +import { z } from "zod"; +import { formatMessages } from "agent/utils/format-messages"; + +function calculateDates( + startDate: string | undefined, + endDate: string | undefined, +): { startDate: Date; endDate: Date } { + const now = new Date(); + + if (!startDate && !endDate) { + // Both undefined: 4 and 5 weeks in future + const start = new Date(now); + start.setDate(start.getDate() + 28); // 4 weeks + const end = new Date(now); + end.setDate(end.getDate() + 35); // 5 weeks + return { startDate: start, endDate: end }; + } + + if (startDate && !endDate) { + // Only start defined: end is 1 week after + const start = new Date(startDate); + const end = new Date(start); + end.setDate(end.getDate() + 7); + return { startDate: start, endDate: end }; + } + + if (!startDate && endDate) { + // Only end defined: start is 1 week before + const end = new Date(endDate); + const start = new Date(end); + start.setDate(start.getDate() - 7); + return { startDate: start, endDate: end }; + } + + // Both defined: use as is + return { + startDate: new Date(startDate!), + endDate: new Date(endDate!), + }; +} + +export async function extraction( + state: TripPlannerState, +): Promise> { + const schema = z.object({ + location: z + .string() + .describe( + "The location to plan the trip for. Can be a city, state, or country.", + ), + startDate: z + .string() + .optional() + .describe("The start date of the trip. Should be in YYYY-MM-DD format"), + endDate: z + .string() + .optional() + .describe("The end date of the trip. Should be in YYYY-MM-DD format"), + numberOfGuests: z + .number() + .optional() + .describe("The number of guests for the trip"), + }); + + const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools([ + { + name: "extract", + description: "A tool to extract information from a user's request.", + schema: schema, + }, + ]); + + const prompt = `You're an AI assistant for planning trips. The user has requested information about a trip they want to go on. +Before you can help them, you need to extract the following information from their request: +- location - The location to plan the trip for. Can be a city, state, or country. +- startDate - The start date of the trip. Should be in YYYY-MM-DD format. Optional +- endDate - The end date of the trip. Should be in YYYY-MM-DD format. Optional +- numberOfGuests - The number of guests for the trip. Optional + +You are provided with the ENTIRE conversation history between you, and the user. Use these messages to extract the necessary information. + +Do NOT guess, or make up any information. If the user did NOT specify a location, please respond with a request for them to specify the location. +You should ONLY send a clarification message if the user did not provide the location. You do NOT need any of the other fields, so if they're missing, proceed without them. +It should be a single sentence, along the lines of "Please specify the location for the trip you want to go on". + +Extract only what is specified by the user. It is okay to leave fields blank if the user did not specify them. +`; + + const humanMessage = `Here is the entire conversation so far:\n${formatMessages(state.messages)}`; + + const response = await model.invoke([ + { role: "system", content: prompt }, + { role: "human", content: humanMessage }, + ]); + + const extractedDetails = response.tool_calls?.[0]?.args as + | z.infer + | undefined; + + if (!extractedDetails) { + return { + messages: [response], + }; + } + + const { startDate, endDate } = calculateDates( + extractedDetails.startDate, + extractedDetails.endDate, + ); + + const extractionDetailsWithDefaults: TripDetails = { + startDate, + endDate, + numberOfGuests: extractedDetails.numberOfGuests ?? 2, + location: extractedDetails.location, + }; + + return { + tripDetails: extractionDetailsWithDefaults, + }; +} diff --git a/agent/trip-planner/nodes/tools.tsx b/agent/trip-planner/nodes/tools.tsx new file mode 100644 index 0000000..9d45486 --- /dev/null +++ b/agent/trip-planner/nodes/tools.tsx @@ -0,0 +1,118 @@ +import { TripPlannerState } from "../types"; +import { ChatOpenAI } from "@langchain/openai"; +import { typedUi } from "@langchain/langgraph-sdk/react-ui/server"; +import type ComponentMap from "../../uis/index"; +import { z } from "zod"; +import { LangGraphRunnableConfig } from "@langchain/langgraph"; + +const schema = z.object({ + listAccommodations: z + .boolean() + .optional() + .describe( + "Whether or not the user has requested a list of accommodations for their trip.", + ), + bookAccommodation: z + .boolean() + .optional() + .describe( + "Whether or not the user has requested to book a reservation for an accommodation. If true, you MUST also set the 'accommodationName' field", + ), + accommodationName: z + .string() + .optional() + .describe( + "The name of the accommodation to book a reservation for. Only required if the 'bookAccommodation' field is true.", + ), + + listRestaurants: z + .boolean() + .optional() + .describe( + "Whether or not the user has requested a list of restaurants for their trip.", + ), + bookRestaurant: z + .boolean() + .optional() + .describe( + "Whether or not the user has requested to book a reservation for a restaurant. If true, you MUST also set the 'restaurantName' field", + ), + restaurantName: z + .string() + .optional() + .describe( + "The name of the restaurant to book a reservation for. Only required if the 'bookRestaurant' field is true.", + ), +}); + +export async function callTools( + state: TripPlannerState, + config: LangGraphRunnableConfig, +): Promise> { + if (!state.tripDetails) { + throw new Error("No trip details found"); + } + + const ui = typedUi(config); + + const llm = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools( + [ + { + name: "trip-planner", + description: "A series of actions to take for planning a trip", + schema, + }, + ], + { + tool_choice: "trip-planner", + }, + ); + + const response = await llm.invoke([ + { + role: "system", + content: + "You are an AI assistant who helps users book trips. Use the user's most recent message(s) to contextually generate a response.", + }, + ...state.messages, + ]); + + const tripPlan = response.tool_calls?.[0]?.args as + | z.infer + | undefined; + if (!tripPlan) { + throw new Error("No trip plan found"); + } + + if (tripPlan.listAccommodations) { + // TODO: Replace with an accommodations list UI component + ui.write("accommodations-list", { tripDetails: state.tripDetails }); + } + if (tripPlan.bookAccommodation && tripPlan.accommodationName) { + // TODO: Replace with a book accommodation UI component + ui.write("book-accommodation", { + tripDetails: state.tripDetails, + accommodationName: tripPlan.accommodationName, + }); + } + + if (tripPlan.listRestaurants) { + // TODO: Replace with a restaurants list UI component + ui.write("restaurants-list", { tripDetails: state.tripDetails }); + } + + if (tripPlan.bookRestaurant && tripPlan.restaurantName) { + // TODO: Replace with a book restaurant UI component + ui.write("book-restaurant", { + tripDetails: state.tripDetails, + restaurantName: tripPlan.restaurantName, + }); + } + + return { + messages: [response], + // TODO: Fix the ui return type. + ui: ui.collect as any[], + timestamp: Date.now(), + }; +} diff --git a/agent/trip-planner/types.ts b/agent/trip-planner/types.ts new file mode 100644 index 0000000..9e546bb --- /dev/null +++ b/agent/trip-planner/types.ts @@ -0,0 +1,18 @@ +import { Annotation } from "@langchain/langgraph"; +import { GenerativeUIAnnotation } from "../types"; + +export type TripDetails = { + location: string; + startDate: Date; + endDate: Date; + numberOfGuests: number; +}; + +export const TripPlannerAnnotation = Annotation.Root({ + messages: GenerativeUIAnnotation.spec.messages, + ui: GenerativeUIAnnotation.spec.ui, + timestamp: GenerativeUIAnnotation.spec.timestamp, + tripDetails: Annotation(), +}); + +export type TripPlannerState = typeof TripPlannerAnnotation.State; diff --git a/agent/types.ts b/agent/types.ts index 40b2d22..a5355bb 100644 --- a/agent/types.ts +++ b/agent/types.ts @@ -5,7 +5,7 @@ export const GenerativeUIAnnotation = Annotation.Root({ messages: MessagesAnnotation.spec["messages"], ui: Annotation({ default: () => [], reducer: uiMessageReducer }), timestamp: Annotation, - next: Annotation<"stockbroker" | "weather" | "generalInput">(), + next: Annotation<"stockbroker" | "tripPlanner" | "generalInput">(), }); export type GenerativeUIState = typeof GenerativeUIAnnotation.State; diff --git a/agent/uis/index.tsx b/agent/uis/index.tsx index a9259be..ad76b8c 100644 --- a/agent/uis/index.tsx +++ b/agent/uis/index.tsx @@ -1,8 +1,16 @@ -import StockPrice from "./stock-price"; -import PortfolioView from "./portfolio-view"; +import StockPrice from "./stockbroker/stock-price"; +import PortfolioView from "./stockbroker/portfolio-view"; +import AccommodationsList from "./trip-planner/accommodations-list"; +import BookAccommodation from "./trip-planner/book-accommodation"; +import RestaurantsList from "./trip-planner/restaurants-list"; +import BookRestaurant from "./trip-planner/book-restaurant"; const ComponentMap = { "stock-price": StockPrice, portfolio: PortfolioView, + "accommodations-list": AccommodationsList, + "book-accommodation": BookAccommodation, + "restaurants-list": RestaurantsList, + "book-restaurant": BookRestaurant, } as const; export default ComponentMap; diff --git a/agent/uis/portfolio-view/index.tsx b/agent/uis/portfolio-view/index.tsx deleted file mode 100644 index 26e240a..0000000 --- a/agent/uis/portfolio-view/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import "./index.css"; - -export default function PortfolioView() { - return ( -
- Portfolio View -
- ); -} diff --git a/agent/uis/stock-price/index.tsx b/agent/uis/stock-price/index.tsx deleted file mode 100644 index f012780..0000000 --- a/agent/uis/stock-price/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import "./index.css"; -import { useStream } from "@langchain/langgraph-sdk/react"; -import type { AIMessage, Message } from "@langchain/langgraph-sdk"; -import { useState } from "react"; - -export default function StockPrice(props: { - instruction: string; - logo: string; -}) { - const [counter, setCounter] = useState(0); - - // useStream should be able to be infered from context - const thread = useStream<{ messages: Message[] }>({ - assistantId: "assistant_123", - apiUrl: "http://localhost:3123", - }); - - const messagesCopy = thread.messages; - - const aiTool = messagesCopy - .slice() - .reverse() - .find( - (message): message is AIMessage => - message.type === "ai" && !!message.tool_calls?.length, - ); - - const toolCallId = aiTool?.tool_calls?.[0]?.id; - - return ( -
- Request: {props.instruction} - -

Counter: {counter}

- {toolCallId && ( - - )} -
- ); -} diff --git a/agent/uis/portfolio-view/index.css b/agent/uis/stockbroker/portfolio-view/index.css similarity index 100% rename from agent/uis/portfolio-view/index.css rename to agent/uis/stockbroker/portfolio-view/index.css diff --git a/agent/uis/stockbroker/portfolio-view/index.tsx b/agent/uis/stockbroker/portfolio-view/index.tsx new file mode 100644 index 0000000..42bb140 --- /dev/null +++ b/agent/uis/stockbroker/portfolio-view/index.tsx @@ -0,0 +1,959 @@ +import "./index.css"; +import { useState } from "react"; + +export default function PortfolioView() { + // Placeholder portfolio data - ideally would come from props + const [portfolio] = useState({ + totalValue: 156842.75, + cashBalance: 12467.32, + performance: { + daily: 1.24, + weekly: -0.52, + monthly: 3.87, + yearly: 14.28, + }, + holdings: [ + { + symbol: "AAPL", + name: "Apple Inc.", + shares: 45, + price: 187.32, + value: 8429.4, + change: 1.2, + allocation: 5.8, + avgCost: 162.5, + }, + { + symbol: "MSFT", + name: "Microsoft Corporation", + shares: 30, + price: 403.78, + value: 12113.4, + change: 0.5, + allocation: 8.4, + avgCost: 340.25, + }, + { + symbol: "AMZN", + name: "Amazon.com Inc.", + shares: 25, + price: 178.75, + value: 4468.75, + change: -0.8, + allocation: 3.1, + avgCost: 145.3, + }, + { + symbol: "GOOGL", + name: "Alphabet Inc.", + shares: 20, + price: 164.85, + value: 3297.0, + change: 2.1, + allocation: 2.3, + avgCost: 125.75, + }, + { + symbol: "NVDA", + name: "NVIDIA Corporation", + shares: 35, + price: 875.28, + value: 30634.8, + change: 3.4, + allocation: 21.3, + avgCost: 520.4, + }, + { + symbol: "TSLA", + name: "Tesla, Inc.", + shares: 40, + price: 175.9, + value: 7036.0, + change: -1.2, + allocation: 4.9, + avgCost: 190.75, + }, + ], + }); + + const [activeTab, setActiveTab] = useState<"holdings" | "performance">( + "holdings", + ); + const [sortConfig, setSortConfig] = useState<{ + key: string; + direction: "asc" | "desc"; + }>({ + key: "allocation", + direction: "desc", + }); + const [selectedHolding, setSelectedHolding] = useState(null); + + const sortedHoldings = [...portfolio.holdings].sort((a, b) => { + if ( + a[sortConfig.key as keyof typeof a] < b[sortConfig.key as keyof typeof b] + ) { + return sortConfig.direction === "asc" ? -1 : 1; + } + if ( + a[sortConfig.key as keyof typeof a] > b[sortConfig.key as keyof typeof b] + ) { + return sortConfig.direction === "asc" ? 1 : -1; + } + return 0; + }); + + const requestSort = (key: string) => { + let direction: "asc" | "desc" = "asc"; + if (sortConfig.key === key && sortConfig.direction === "asc") { + direction = "desc"; + } + setSortConfig({ key, direction }); + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value); + }; + + const formatPercent = (value: number) => { + return `${value > 0 ? "+" : ""}${value.toFixed(2)}%`; + }; + + // Faux chart data for selected holding + const generateChartData = (symbol: string) => { + const data = []; + const basePrice = + portfolio.holdings.find((h) => h.symbol === symbol)?.price || 100; + + for (let i = 0; i < 30; i++) { + const date = new Date(); + date.setDate(date.getDate() - 30 + i); + + const randomFactor = (Math.sin(i / 5) + Math.random() - 0.5) * 0.05; + const price = basePrice * (1 + randomFactor * (i / 3)); + + data.push({ + date: date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + price: parseFloat(price.toFixed(2)), + }); + } + + return data; + }; + + // Calculate total value and percent change for display + const totalChange = portfolio.holdings.reduce( + (acc, curr) => acc + (curr.price - curr.avgCost) * curr.shares, + 0, + ); + const totalPercentChange = + (totalChange / (portfolio.totalValue - totalChange)) * 100; + + const selectedStock = selectedHolding + ? portfolio.holdings.find((h) => h.symbol === selectedHolding) + : null; + const chartData = selectedHolding ? generateChartData(selectedHolding) : []; + + return ( +
+
+
+

+ + + + + Portfolio Summary +

+
+ + + + Updated: {new Date().toLocaleString()} +
+
+
+ +
+
+
+
+

Total Value

+ + + +
+

+ {formatCurrency(portfolio.totalValue)} +

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {totalPercentChange >= 0 ? ( + + + + ) : ( + + + + )} + {formatPercent(totalPercentChange)} All Time +

+
+
+
+

Cash Balance

+ + + + +
+

+ {formatCurrency(portfolio.cashBalance)} +

+

+ {((portfolio.cashBalance / portfolio.totalValue) * 100).toFixed( + 1, + )} + % of portfolio +

+
+
+
+

Daily Change

+ + + +
+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {formatPercent(portfolio.performance.daily)} +

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {formatCurrency( + (portfolio.totalValue * portfolio.performance.daily) / 100, + )} +

+
+
+ +
+
+ + +
+
+ + {activeTab === "holdings" && !selectedHolding && ( +
+ + + + + + + + + + + + + + {sortedHoldings.map((holding) => ( + setSelectedHolding(holding.symbol)} + > + + + + + + + + + ))} + +
requestSort("symbol")} + className="group px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" + > +
+ Symbol + + {sortConfig.key === "symbol" + ? sortConfig.direction === "asc" + ? "↑" + : "↓" + : "↕"} + +
+
+ Company + requestSort("shares")} + className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" + > +
+ Shares + + {sortConfig.key === "shares" + ? sortConfig.direction === "asc" + ? "↑" + : "↓" + : "↕"} + +
+
requestSort("price")} + className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" + > +
+ Price + + {sortConfig.key === "price" + ? sortConfig.direction === "asc" + ? "↑" + : "↓" + : "↕"} + +
+
requestSort("change")} + className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" + > +
+ Change + + {sortConfig.key === "change" + ? sortConfig.direction === "asc" + ? "↑" + : "↓" + : "↕"} + +
+
requestSort("value")} + className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" + > +
+ Value + + {sortConfig.key === "value" + ? sortConfig.direction === "asc" + ? "↑" + : "↓" + : "↕"} + +
+
requestSort("allocation")} + className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" + > +
+ Allocation + + {sortConfig.key === "allocation" + ? sortConfig.direction === "asc" + ? "↑" + : "↓" + : "↕"} + +
+
+ {holding.symbol} + + {holding.name} + + {holding.shares.toLocaleString()} + + {formatCurrency(holding.price)} + = 0 ? "text-green-600" : "text-red-600"}`} + > + {holding.change >= 0 ? ( + + + + ) : ( + + + + )} + {formatPercent(holding.change)} + + {formatCurrency(holding.value)} + +
+
+
= 0 ? "bg-green-500" : "bg-red-500"}`} + style={{ + width: `${Math.min(100, holding.allocation * 3)}%`, + }} + >
+
+ + {holding.allocation.toFixed(1)}% + +
+
+
+ )} + + {activeTab === "holdings" && selectedHolding && selectedStock && ( +
+
+
+
+

+ {selectedStock.symbol} +

+ + {selectedStock.name} + +
+
+ + {formatCurrency(selectedStock.price)} + + = 0 ? "text-green-600" : "text-red-600"}`} + > + {selectedStock.change >= 0 ? "▲" : "▼"}{" "} + {formatPercent(selectedStock.change)} + +
+
+ +
+ +
+
+
+ {chartData.map((point, index) => { + const maxPrice = Math.max(...chartData.map((d) => d.price)); + const minPrice = Math.min(...chartData.map((d) => d.price)); + const range = maxPrice - minPrice; + const heightPercent = + range === 0 + ? 50 + : ((point.price - minPrice) / range) * 80 + 10; + + return ( +
+
= chartData[Math.max(0, index - 1)].price ? "bg-green-500" : "bg-red-500"}`} + style={{ height: `${heightPercent}%` }} + >
+ {index % 5 === 0 && ( + + {point.date} + + )} +
+ ); + })} +
+
+
+ +
+
+
+

Shares Owned

+

+ {selectedStock.shares.toLocaleString()} +

+
+
+

Market Value

+

+ {formatCurrency(selectedStock.value)} +

+
+
+

Avg. Cost

+

+ {formatCurrency(selectedStock.avgCost)} +

+
+
+

Cost Basis

+

+ {formatCurrency( + selectedStock.avgCost * selectedStock.shares, + )} +

+
+
+

Gain/Loss

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {formatCurrency( + (selectedStock.price - selectedStock.avgCost) * + selectedStock.shares, + )} +

+
+
+

Allocation

+

+ {selectedStock.allocation.toFixed(2)}% +

+
+
+
+ +
+ + + +
+
+ )} + + {activeTab === "performance" && ( +
+
+

+ + + + Performance Overview +

+
+
+

Daily

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {portfolio.performance.daily >= 0 ? ( + + + + ) : ( + + + + )} + {formatPercent(portfolio.performance.daily)} +

+
+
+

Weekly

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {portfolio.performance.weekly >= 0 ? ( + + + + ) : ( + + + + )} + {formatPercent(portfolio.performance.weekly)} +

+
+
+

Monthly

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {portfolio.performance.monthly >= 0 ? ( + + + + ) : ( + + + + )} + {formatPercent(portfolio.performance.monthly)} +

+
+
+

Yearly

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {portfolio.performance.yearly >= 0 ? ( + + + + ) : ( + + + + )} + {formatPercent(portfolio.performance.yearly)} +

+
+
+
+ +
+

+ + + + + Portfolio Allocation +

+
+ {sortedHoldings.map((holding) => ( +
+
+
= 0 ? "bg-green-500" : "bg-red-500"}`} + >
+ {holding.symbol} +
+
+
+
+
+
+
+ {holding.allocation.toFixed(1)}% +
+
+ +
+
+ ))} +
+ +
+

+ Portfolio Diversification +

+
+ {[ + "Technology", + "Consumer Cyclical", + "Communication Services", + "Financial", + "Other", + ].map((sector, index) => { + const widths = [42, 23, 18, 10, 7]; // example percentages + const colors = [ + "bg-indigo-600", + "bg-blue-500", + "bg-green-500", + "bg-yellow-500", + "bg-red-500", + ]; + return ( +
+ ); + })} +
+
+ {[ + "Technology", + "Consumer Cyclical", + "Communication Services", + "Financial", + "Other", + ].map((sector, index) => { + const widths = [42, 23, 18, 10, 7]; // example percentages + const colors = [ + "text-indigo-600", + "text-blue-500", + "text-green-500", + "text-yellow-500", + "text-red-500", + ]; + return ( +
+
+ + {sector} {widths[index]}% + +
+ ); + })} +
+
+
+ +
+ + +
+
+ )} +
+
+ ); +} diff --git a/agent/uis/stock-price/index.css b/agent/uis/stockbroker/stock-price/index.css similarity index 100% rename from agent/uis/stock-price/index.css rename to agent/uis/stockbroker/stock-price/index.css diff --git a/agent/uis/stockbroker/stock-price/index.tsx b/agent/uis/stockbroker/stock-price/index.tsx new file mode 100644 index 0000000..f279781 --- /dev/null +++ b/agent/uis/stockbroker/stock-price/index.tsx @@ -0,0 +1,953 @@ +import "./index.css"; +import { useStream } from "@langchain/langgraph-sdk/react"; +import type { AIMessage, Message } from "@langchain/langgraph-sdk"; +import { useState, useEffect, useCallback } from "react"; + +export default function StockPrice(props: { + instruction: string; + logo: string; +}) { + const [quantity, setQuantity] = useState(1); + const [stockData, setStockData] = useState({ + symbol: "AAPL", + name: "Apple Inc.", + price: 187.32, + change: 1.24, + changePercent: 0.67, + previousClose: 186.08, + open: 186.75, + dayHigh: 188.45, + dayLow: 186.2, + volume: 54320000, + marketCap: "2.92T", + peRatio: 29.13, + dividend: 0.96, + dividendYield: 0.51, + moving50Day: 182.46, + moving200Day: 175.8, + fiftyTwoWeekHigh: 201.48, + fiftyTwoWeekLow: 143.9, + analystRating: "Buy", + analystCount: 32, + priceTarget: 210.5, + }); + + const [priceHistory, setPriceHistory] = useState< + { time: string; price: number }[] + >([]); + const [orderType, setOrderType] = useState<"buy" | "sell">("buy"); + const [showOrder, setShowOrder] = useState(false); + const [activeTab, setActiveTab] = useState<"chart" | "details" | "analysis">( + "chart", + ); + const [isLiveUpdating, setIsLiveUpdating] = useState(false); + const [orderTypeOptions] = useState([ + { value: "market", label: "Market" }, + { value: "limit", label: "Limit" }, + { value: "stop", label: "Stop" }, + ]); + const [selectedOrderTypeOption, setSelectedOrderTypeOption] = + useState("market"); + const [limitPrice, setLimitPrice] = useState(stockData.price.toFixed(2)); + const [showOrderSuccess, setShowOrderSuccess] = useState(false); + + // useStream should be able to be infered from context + const thread = useStream<{ messages: Message[] }>({ + assistantId: "assistant_123", + apiUrl: "http://localhost:3123", + }); + + const messagesCopy = thread.messages; + + const aiTool = messagesCopy + .slice() + .reverse() + .find( + (message): message is AIMessage => + message.type === "ai" && !!message.tool_calls?.length, + ); + + const toolCallId = aiTool?.tool_calls?.[0]?.id; + + // Simulated price history generation on component mount + useEffect(() => { + generatePriceHistory(); + }, []); + + const generatePriceHistory = () => { + const now = new Date(); + const history = []; + + for (let i = 30; i >= 0; i--) { + const time = new Date(now.getTime() - i * 15 * 60000); // 15-minute intervals + const basePrice = 187.32; + // Make the price movement more interesting with some trends + const trend = Math.sin(i / 5) * 1.5; + const randomFactor = (Math.random() - 0.5) * 1.5; + const price = basePrice + trend + randomFactor; + + history.push({ + time: time.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }), + price: parseFloat(price.toFixed(2)), + }); + } + + setPriceHistory(history); + }; + + // Simulate live price updates + useEffect(() => { + let interval: NodeJS.Timeout; + + if (isLiveUpdating) { + interval = setInterval(() => { + setStockData((prev) => { + // Random small price movement + const priceChange = (Math.random() - 0.5) * 0.3; + const newPrice = parseFloat((prev.price + priceChange).toFixed(2)); + + // Update price history + setPriceHistory((history) => { + const now = new Date(); + const newHistory = [...history]; + if (newHistory.length > 30) { + newHistory.shift(); // Remove oldest entry to keep array length consistent + } + newHistory.push({ + time: now.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }), + price: newPrice, + }); + return newHistory; + }); + + return { + ...prev, + price: newPrice, + change: parseFloat((newPrice - prev.previousClose).toFixed(2)), + changePercent: parseFloat( + ( + ((newPrice - prev.previousClose) / prev.previousClose) * + 100 + ).toFixed(2), + ), + dayHigh: Math.max(prev.dayHigh, newPrice), + dayLow: Math.min(prev.dayLow, newPrice), + }; + }); + }, 3000); + } + + return () => { + if (interval) clearInterval(interval); + }; + }, [isLiveUpdating]); + + const handleQuantityChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value); + if (value > 0 && value <= 1000) { + setQuantity(value); + } + }; + + const handleLimitPriceChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (/^\d*\.?\d*$/.test(value)) { + setLimitPrice(value); + } + }; + + const toggleLiveUpdates = () => { + setIsLiveUpdating((prev) => !prev); + }; + + const handleOrder = () => { + // Submit the order through the thread + if (toolCallId) { + const orderDetails = { + action: orderType, + quantity, + symbol: stockData.symbol, + orderType: selectedOrderTypeOption, + ...(selectedOrderTypeOption !== "market" && { + limitPrice: parseFloat(limitPrice), + }), + }; + + thread.submit({ + messages: [ + { + type: "tool", + tool_call_id: toolCallId, + name: "stockbroker", + content: JSON.stringify(orderDetails), + }, + { + type: "human", + content: `${orderType === "buy" ? "Bought" : "Sold"} ${quantity} shares of ${stockData.symbol} at ${ + selectedOrderTypeOption === "market" + ? formatCurrency(stockData.price) + : formatCurrency(parseFloat(limitPrice)) + }`, + }, + ], + }); + + setShowOrderSuccess(true); + setTimeout(() => { + setShowOrderSuccess(false); + setShowOrder(false); + }, 2000); + } + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value); + }; + + const formatPercent = (value: number) => { + return `${value > 0 ? "+" : ""}${value.toFixed(2)}%`; + }; + + const getMinMax = () => { + if (priceHistory.length === 0) return { min: 0, max: 0 }; + const prices = priceHistory.map((item) => item.price); + return { + min: Math.min(...prices), + max: Math.max(...prices), + }; + }; + + // Generate a simple price chart with the last price as endpoint + const generateChartPath = useCallback(() => { + if (priceHistory.length === 0) return ""; + + const { min, max } = getMinMax(); + const range = max - min || 1; + const width = 100; // % of container width + const height = 100; // % of container height + + const points = priceHistory.map((point, i) => { + const x = (i / (priceHistory.length - 1)) * width; + const y = height - ((point.price - min) / range) * height; + return `${x},${y}`; + }); + + return `M ${points.join(" L ")}`; + }, [priceHistory]); + + const { min, max } = getMinMax(); + const range = max - min || 1; + const chartPath = generateChartPath(); + + return ( +
+
= 0 ? "bg-gradient-to-r from-green-600 to-green-500" : "bg-gradient-to-r from-red-600 to-red-500"}`} + > +
+
+ {props.logo && ( + Logo + )} +

+ {stockData.symbol}{" "} + + {stockData.name} + +

+
+
+ + {formatCurrency(stockData.price)} + +
= 0 ? "bg-green-700/50" : "bg-red-700/50"} backdrop-blur-sm border ${stockData.change >= 0 ? "border-green-400/30" : "border-red-400/30"}`} + > + {stockData.change >= 0 ? "▲" : "▼"}{" "} + {formatCurrency(Math.abs(stockData.change))} ( + {formatPercent(stockData.changePercent)}) +
+
+
+
+ {props.instruction} + +
+
+ +
+
+ + + +
+
+ +
+ {activeTab === "chart" && ( + <> +
+
+
+ Price Chart +
+ + + + +
+
+ +
+
+ {/* Chart grid lines */} +
+ {[0, 1, 2, 3].map((line) => ( +
+ ))} +
+ + {/* SVG Line Chart */} + + {/* Chart path */} + = 0 ? "#10b981" : "#ef4444"} + strokeWidth="1.5" + fill="none" + strokeLinecap="round" + /> + + {/* Gradient area under the chart */} + = 0 + ? "url(#greenGradient)" + : "url(#redGradient)" + } + fillOpacity="0.2" + /> + + {/* Gradient definitions */} + + + + + + + + + + + + + {/* Price labels on Y-axis */} +
+ {formatCurrency(max)} + {formatCurrency(min + range * 0.66)} + {formatCurrency(min + range * 0.33)} + {formatCurrency(min)} +
+
+
+ +
+
+ + {priceHistory[0]?.time || "9:30 AM"} +
+
+ Vol: {stockData.volume.toLocaleString()} +
+
+ + {priceHistory[priceHistory.length - 1]?.time || "4:00 PM"} +
+
+
+
+ + {/* Key Stock Information */} +
+
+

Day Range

+
+
+
= 0 ? "bg-green-500" : "bg-red-500"}`} + style={{ + width: `${((stockData.price - stockData.dayLow) / (stockData.dayHigh - stockData.dayLow)) * 100}%`, + }} + >
+
+
+ {formatCurrency(stockData.dayLow)} + {formatCurrency(stockData.dayHigh)} +
+
+
+
+

+ 52-Week Range +

+
+
+
+
+
+ {formatCurrency(stockData.fiftyTwoWeekLow)} + {formatCurrency(stockData.fiftyTwoWeekHigh)} +
+
+
+
+ + )} + + {activeTab === "details" && ( +
+
+
+

Open

+

{formatCurrency(stockData.open)}

+
+
+

+ Previous Close +

+

+ {formatCurrency(stockData.previousClose)} +

+
+
+

+ Market Cap +

+

{stockData.marketCap}

+
+
+

Volume

+

+ {stockData.volume.toLocaleString()} +

+
+
+

+ P/E Ratio +

+

{stockData.peRatio}

+
+
+

+ Dividend Yield +

+

{stockData.dividendYield}%

+
+
+

+ 50-Day Avg +

+

+ {formatCurrency(stockData.moving50Day)} +

+
+
+

+ 200-Day Avg +

+

+ {formatCurrency(stockData.moving200Day)} +

+
+
+ +
+

+ Company Overview +

+

+ {stockData.name} is a leading technology company that designs, + manufactures, and markets consumer electronics, computer + software, and online services. The company has a strong global + presence and is known for its innovation in the industry. +

+
+
+ )} + + {activeTab === "analysis" && ( +
+
+
+

Analyst Consensus

+
+ {stockData.analystRating} +
+
+ +
+
Strong Buy
+
+
+
+
+
+
65%
+
+
+
Buy
+
+
+
+
+
+
20%
+
+
+
Hold
+
+
+
+
+
+
10%
+
+
+
Sell
+
+
+
+
+
+
5%
+
+ +
+ Based on {stockData.analystCount} analyst ratings +
+
+ +
+
+

Price Target

+ + {formatCurrency(stockData.priceTarget)} + +
+ +
+
+
+ + + + {( + (stockData.priceTarget / stockData.price - 1) * + 100 + ).toFixed(2)} + % + {" "} + Upside +
+
+
+
+
+
+ Current: {formatCurrency(stockData.price)} + Target: {formatCurrency(stockData.priceTarget)} +
+
+
+ +
+

Recent News

+
+
+

+ {stockData.name} Reports Strong Quarterly Earnings +

+

2 days ago

+
+
+

+ New Product Launch Expected Next Month +

+

5 days ago

+
+
+
+
+ )} + + {/* Order Interface */} + {!showOrder && !showOrderSuccess ? ( +
+ + +
+ ) : showOrderSuccess ? ( +
+ + + + Order submitted successfully! +
+ ) : ( +
+
+

+ {orderType === "buy" ? "Buy" : "Sell"} {stockData.symbol} +

+ +
+ +
+
+ {orderTypeOptions.map((option) => ( + + ))} +
+ +
+
+ +
+ + + +
+
+ + {selectedOrderTypeOption !== "market" && ( +
+ +
+ + $ + + +
+
+ )} +
+ + {selectedOrderTypeOption !== "market" && ( +
+
+
+ + + +
+
+

+ {selectedOrderTypeOption === "limit" + ? `Your ${orderType} order will execute only at ${formatCurrency(parseFloat(limitPrice))} or better.` + : `Your ${orderType} order will execute when the price reaches ${formatCurrency(parseFloat(limitPrice))}.`} +

+
+
+
+ )} + +
+
+ Market Price + + {formatCurrency(stockData.price)} + +
+ +
+ + {orderType === "buy" ? "Cost" : "Credit"} ({quantity}{" "} + {quantity === 1 ? "share" : "shares"}) + + {formatCurrency(stockData.price * quantity)} +
+ +
+ Commission + $0.00 +
+ +
+ Estimated Total + + {formatCurrency(stockData.price * quantity)} + +
+
+
+ +
+ + +
+
+ )} +
+
+ ); +} diff --git a/agent/uis/trip-planner/accommodations-list/index.css b/agent/uis/trip-planner/accommodations-list/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/agent/uis/trip-planner/accommodations-list/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/agent/uis/trip-planner/accommodations-list/index.tsx b/agent/uis/trip-planner/accommodations-list/index.tsx new file mode 100644 index 0000000..e671e5e --- /dev/null +++ b/agent/uis/trip-planner/accommodations-list/index.tsx @@ -0,0 +1,195 @@ +import "./index.css"; +import { TripDetails } from "../../../trip-planner/types"; +import { useState } from "react"; + +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 [selectedId, setSelectedId] = useState(null); + + const selectedAccommodation = accommodations.find( + (acc) => acc.id === selectedId, + ); + + 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/agent/uis/trip-planner/book-accommodation/index.css b/agent/uis/trip-planner/book-accommodation/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/agent/uis/trip-planner/book-accommodation/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/agent/uis/trip-planner/book-accommodation/index.tsx b/agent/uis/trip-planner/book-accommodation/index.tsx new file mode 100644 index 0000000..68d6d7e --- /dev/null +++ b/agent/uis/trip-planner/book-accommodation/index.tsx @@ -0,0 +1,403 @@ +import "./index.css"; +import { TripDetails } from "../../../trip-planner/types"; +import { useState } from "react"; + +export default function BookAccommodation({ + tripDetails, + accommodationName, +}: { + tripDetails: TripDetails; + accommodationName: string; +}) { + // Placeholder data - ideally would come from props + const [accommodation] = useState({ + name: accommodationName, + type: "Hotel", + price: "$150/night", + rating: 4.8, + totalPrice: + "$" + + 150 * + Math.ceil( + (new Date(tripDetails.endDate).getTime() - + new Date(tripDetails.startDate).getTime()) / + (1000 * 60 * 60 * 24), + ), + image: "https://placehold.co/300x200?text=Accommodation", + roomTypes: ["Standard", "Deluxe", "Suite"], + checkInTime: "3:00 PM", + checkOutTime: "11:00 AM", + }); + + const [selectedRoom, setSelectedRoom] = useState("Standard"); + const [bookingStep, setBookingStep] = useState< + "details" | "payment" | "confirmed" + >("details"); + const [formData, setFormData] = useState({ + name: "", + email: "", + phone: "", + specialRequests: "", + }); + + const handleInputChange = ( + e: React.ChangeEvent, + ) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setBookingStep("payment"); + }; + + const handlePayment = (e: React.FormEvent) => { + e.preventDefault(); + setBookingStep("confirmed"); + }; + + return ( +
+
+

Book {accommodation.name}

+

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

+
+ +
+ {bookingStep === "details" && ( + <> +
+
+ {accommodation.name} +
+
+

+ {accommodation.name} +

+
+ + + + + {accommodation.rating} + +
+
+ + {accommodation.type} + + + {accommodation.price} + +
+
+
+ +
+
+ Check-in + + {new Date(tripDetails.startDate).toLocaleDateString()} ( + {accommodation.checkInTime}) + +
+
+ Check-out + + {new Date(tripDetails.endDate).toLocaleDateString()} ( + {accommodation.checkOutTime}) + +
+
+ Guests + + {tripDetails.numberOfGuests} + +
+
+ +
+ +
+ {accommodation.roomTypes.map((room) => ( + + ))} +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +