Merge branch 'main' into brace/fix-branching

This commit is contained in:
Tat Dat Duong
2025-03-04 14:07:15 +01:00
30 changed files with 3672 additions and 114 deletions

View File

@@ -4,18 +4,22 @@ import { z } from "zod";
import { GenerativeUIAnnotation, GenerativeUIState } from "./types"; import { GenerativeUIAnnotation, GenerativeUIState } from "./types";
import { stockbrokerGraph } from "./stockbroker"; import { stockbrokerGraph } from "./stockbroker";
import { ChatOpenAI } from "@langchain/openai"; 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( async function router(
state: GenerativeUIState, state: GenerativeUIState,
): Promise<Partial<GenerativeUIState>> { ): Promise<Partial<GenerativeUIState>> {
const routerDescription = `The route to take based on the user's input. 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 ${allToolDescriptions}
- weather: can fetch the current weather conditions for a location
- generalInput: handles all other cases where the above tools don't apply - generalInput: handles all other cases where the above tools don't apply
`; `;
const routerSchema = z.object({ const routerSchema = z.object({
route: z route: z
.enum(["stockbroker", "weather", "generalInput"]) .enum(["stockbroker", "tripPlanner", "generalInput"])
.describe(routerDescription), .describe(routerDescription),
}); });
const routerTool = { 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. 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.`; You should analyze the user's input, and choose the appropriate tool to use.`;
const recentHumanMessage = state.messages.findLast( const allMessagesButLast = state.messages.slice(0, -1);
(m) => m.getType() === "human", const lastMessage = state.messages.at(-1);
);
if (!recentHumanMessage) { const formattedPreviousMessages = formatMessages(allMessagesButLast);
throw new Error("No human message found in state"); 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([ const response = await llm.invoke([
{ role: "system", content: prompt }, { role: "system", content: prompt },
recentHumanMessage, { role: "user", content: humanMessage },
]); ]);
const toolCall = response.tool_calls?.[0]?.args as 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( function handleRoute(
state: GenerativeUIState, state: GenerativeUIState,
): "stockbroker" | "weather" | "generalInput" { ): "stockbroker" | "tripPlanner" | "generalInput" {
return state.next; return state.next;
} }
async function handleGeneralInput(state: GenerativeUIState) { async function handleGeneralInput(state: GenerativeUIState) {
const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 }); 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 { return {
messages: [response], messages: [response],
@@ -77,19 +95,17 @@ async function handleGeneralInput(state: GenerativeUIState) {
const builder = new StateGraph(GenerativeUIAnnotation) const builder = new StateGraph(GenerativeUIAnnotation)
.addNode("router", router) .addNode("router", router)
.addNode("stockbroker", stockbrokerGraph) .addNode("stockbroker", stockbrokerGraph)
.addNode("weather", () => { .addNode("tripPlanner", tripPlannerGraph)
throw new Error("Weather not implemented");
})
.addNode("generalInput", handleGeneralInput) .addNode("generalInput", handleGeneralInput)
.addConditionalEdges("router", handleRoute, [ .addConditionalEdges("router", handleRoute, [
"stockbroker", "stockbroker",
"weather", "tripPlanner",
"generalInput", "generalInput",
]) ])
.addEdge(START, "router") .addEdge(START, "router")
.addEdge("stockbroker", END) .addEdge("stockbroker", END)
.addEdge("weather", END) .addEdge("tripPlanner", END)
.addEdge("generalInput", END); .addEdge("generalInput", END);
export const graph = builder.compile(); export const graph = builder.compile();

View File

@@ -5,7 +5,6 @@ export const StockbrokerAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages, messages: GenerativeUIAnnotation.spec.messages,
ui: GenerativeUIAnnotation.spec.ui, ui: GenerativeUIAnnotation.spec.ui,
timestamp: GenerativeUIAnnotation.spec.timestamp, timestamp: GenerativeUIAnnotation.spec.timestamp,
next: Annotation<"stockbroker" | "weather">(),
}); });
export type StockbrokerState = typeof StockbrokerAnnotation.State; export type StockbrokerState = typeof StockbrokerAnnotation.State;

View File

@@ -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";

View File

@@ -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<Partial<TripPlannerState>> {
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<typeof schema>
| 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 {};
}

View File

@@ -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<Partial<TripPlannerState>> {
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<typeof schema>
| 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,
};
}

View File

@@ -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<Partial<TripPlannerState>> {
if (!state.tripDetails) {
throw new Error("No trip details found");
}
const ui = typedUi<typeof ComponentMap>(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<typeof schema>
| 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(),
};
}

View File

@@ -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<TripDetails | undefined>(),
});
export type TripPlannerState = typeof TripPlannerAnnotation.State;

View File

@@ -5,7 +5,7 @@ export const GenerativeUIAnnotation = Annotation.Root({
messages: MessagesAnnotation.spec["messages"], messages: MessagesAnnotation.spec["messages"],
ui: Annotation({ default: () => [], reducer: uiMessageReducer }), ui: Annotation({ default: () => [], reducer: uiMessageReducer }),
timestamp: Annotation<number>, timestamp: Annotation<number>,
next: Annotation<"stockbroker" | "weather" | "generalInput">(), next: Annotation<"stockbroker" | "tripPlanner" | "generalInput">(),
}); });
export type GenerativeUIState = typeof GenerativeUIAnnotation.State; export type GenerativeUIState = typeof GenerativeUIAnnotation.State;

View File

@@ -1,8 +1,16 @@
import StockPrice from "./stock-price"; import StockPrice from "./stockbroker/stock-price";
import PortfolioView from "./portfolio-view"; 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 = { const ComponentMap = {
"stock-price": StockPrice, "stock-price": StockPrice,
portfolio: PortfolioView, portfolio: PortfolioView,
"accommodations-list": AccommodationsList,
"book-accommodation": BookAccommodation,
"restaurants-list": RestaurantsList,
"book-restaurant": BookRestaurant,
} as const; } as const;
export default ComponentMap; export default ComponentMap;

View File

@@ -1,9 +0,0 @@
import "./index.css";
export default function PortfolioView() {
return (
<div className="flex flex-col gap-2 border border-solid border-slate-500 p-4 rounded-md">
Portfolio View
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-2 border border-solid border-slate-500 p-4 rounded-md">
Request: {props.instruction}
<button className="text-left" onClick={() => setCounter(counter + 1)}>
Click me
</button>
<p>Counter: {counter}</p>
{toolCallId && (
<button
className="text-left"
onClick={() => {
thread.submit({
messages: [
{
type: "tool",
tool_call_id: toolCallId!,
name: "stockbroker",
content: "hey",
},
{ type: "human", content: `Buy ${counter}` },
],
});
}}
>
Buy
</button>
)}
</div>
);
}

View File

@@ -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<string | null>(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 (
<div className="w-full max-w-4xl bg-white rounded-xl shadow-lg overflow-hidden border border-gray-200">
<div className="bg-gradient-to-r from-indigo-700 to-indigo-500 px-6 py-4">
<div className="flex justify-between items-center">
<h2 className="text-white font-bold text-xl tracking-tight flex items-center">
<svg
className="w-6 h-6 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"></path>
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"></path>
</svg>
Portfolio Summary
</h2>
<div className="bg-indigo-800/50 text-white px-3 py-1 rounded-md text-sm backdrop-blur-sm border border-indigo-400/30 flex items-center">
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
clipRule="evenodd"
></path>
</svg>
Updated: {new Date().toLocaleString()}
</div>
</div>
</div>
<div className="p-6 bg-gradient-to-b from-indigo-50 to-white">
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
<div className="flex justify-between">
<p className="text-gray-500 text-sm font-medium">Total Value</p>
<svg
className="w-5 h-5 text-indigo-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z"
clipRule="evenodd"
></path>
</svg>
</div>
<p className="text-2xl font-bold text-gray-900 mt-1">
{formatCurrency(portfolio.totalValue)}
</p>
<p
className={`text-xs mt-1 flex items-center ${totalPercentChange >= 0 ? "text-green-600" : "text-red-600"}`}
>
{totalPercentChange >= 0 ? (
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(totalPercentChange)} All Time
</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
<div className="flex justify-between">
<p className="text-gray-500 text-sm font-medium">Cash Balance</p>
<svg
className="w-5 h-5 text-indigo-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M8.433 7.418c.155-.103.346-.196.567-.267v1.698a2.305 2.305 0 01-.567-.267C8.07 8.34 8 8.114 8 8c0-.114.07-.34.433-.582zM11 12.849v-1.698c.22.071.412.164.567.267.364.243.433.468.433.582 0 .114-.07.34-.433.582a2.305 2.305 0 01-.567.267z"></path>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662v1.941c-.391-.127-.68-.317-.843-.504a1 1 0 10-1.51 1.31c.562.649 1.413 1.076 2.353 1.253V15a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 13.766 14 12.991 14 12c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 9.092V7.151c.391.127.68.317.843.504a1 1 0 101.511-1.31c-.563-.649-1.413-1.076-2.354-1.253V5z"
clipRule="evenodd"
></path>
</svg>
</div>
<p className="text-2xl font-bold text-gray-900 mt-1">
{formatCurrency(portfolio.cashBalance)}
</p>
<p className="text-xs mt-1 text-gray-500">
{((portfolio.cashBalance / portfolio.totalValue) * 100).toFixed(
1,
)}
% of portfolio
</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
<div className="flex justify-between">
<p className="text-gray-500 text-sm font-medium">Daily Change</p>
<svg
className="w-5 h-5 text-indigo-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
</div>
<p
className={`text-2xl font-bold mt-1 ${portfolio.performance.daily >= 0 ? "text-green-600" : "text-red-600"}`}
>
{formatPercent(portfolio.performance.daily)}
</p>
<p
className={`text-xs mt-1 ${portfolio.performance.daily >= 0 ? "text-green-600" : "text-red-600"}`}
>
{formatCurrency(
(portfolio.totalValue * portfolio.performance.daily) / 100,
)}
</p>
</div>
</div>
<div className="border-b border-gray-200 mb-4">
<div className="flex space-x-4">
<button
onClick={() => {
setActiveTab("holdings");
setSelectedHolding(null);
}}
className={`px-4 py-2 font-medium text-sm focus:outline-none ${
activeTab === "holdings"
? "text-indigo-600 border-b-2 border-indigo-600 font-semibold"
: "text-gray-500 hover:text-gray-700"
}`}
>
Holdings
</button>
<button
onClick={() => {
setActiveTab("performance");
setSelectedHolding(null);
}}
className={`px-4 py-2 font-medium text-sm focus:outline-none ${
activeTab === "performance"
? "text-indigo-600 border-b-2 border-indigo-600 font-semibold"
: "text-gray-500 hover:text-gray-700"
}`}
>
Performance
</button>
</div>
</div>
{activeTab === "holdings" && !selectedHolding && (
<div className="overflow-x-auto rounded-lg border border-gray-200 shadow-sm">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
onClick={() => 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"
>
<div className="flex items-center">
<span>Symbol</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "symbol"
? sortConfig.direction === "asc"
? "↑"
: "↓"
: "↕"}
</span>
</div>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Company
</th>
<th
onClick={() => 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"
>
<div className="flex items-center justify-end">
<span>Shares</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "shares"
? sortConfig.direction === "asc"
? "↑"
: "↓"
: "↕"}
</span>
</div>
</th>
<th
onClick={() => 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"
>
<div className="flex items-center justify-end">
<span>Price</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "price"
? sortConfig.direction === "asc"
? "↑"
: "↓"
: "↕"}
</span>
</div>
</th>
<th
onClick={() => 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"
>
<div className="flex items-center justify-end">
<span>Change</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "change"
? sortConfig.direction === "asc"
? "↑"
: "↓"
: "↕"}
</span>
</div>
</th>
<th
onClick={() => 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"
>
<div className="flex items-center justify-end">
<span>Value</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "value"
? sortConfig.direction === "asc"
? "↑"
: "↓"
: "↕"}
</span>
</div>
</th>
<th
onClick={() => 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"
>
<div className="flex items-center justify-end">
<span>Allocation</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "allocation"
? sortConfig.direction === "asc"
? "↑"
: "↓"
: "↕"}
</span>
</div>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedHoldings.map((holding) => (
<tr
key={holding.symbol}
className="hover:bg-indigo-50 cursor-pointer transition-colors"
onClick={() => setSelectedHolding(holding.symbol)}
>
<td className="px-4 py-4 text-sm font-medium text-indigo-600">
{holding.symbol}
</td>
<td className="px-4 py-4 text-sm text-gray-900">
{holding.name}
</td>
<td className="px-4 py-4 text-sm text-gray-900 text-right">
{holding.shares.toLocaleString()}
</td>
<td className="px-4 py-4 text-sm text-gray-900 text-right">
{formatCurrency(holding.price)}
</td>
<td
className={`px-4 py-4 text-sm text-right font-medium flex items-center justify-end ${holding.change >= 0 ? "text-green-600" : "text-red-600"}`}
>
{holding.change >= 0 ? (
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(holding.change)}
</td>
<td className="px-4 py-4 text-sm text-gray-900 text-right font-medium">
{formatCurrency(holding.value)}
</td>
<td className="px-4 py-4 text-right">
<div className="flex items-center justify-end">
<div className="w-16 bg-gray-200 h-2 rounded-full overflow-hidden mr-2">
<div
className={`h-2 ${holding.change >= 0 ? "bg-green-500" : "bg-red-500"}`}
style={{
width: `${Math.min(100, holding.allocation * 3)}%`,
}}
></div>
</div>
<span className="text-sm text-gray-900">
{holding.allocation.toFixed(1)}%
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === "holdings" && selectedHolding && selectedStock && (
<div className="rounded-lg border border-gray-200 shadow-sm bg-white">
<div className="p-4 flex justify-between items-start">
<div>
<div className="flex items-center">
<h3 className="text-xl font-bold text-gray-900">
{selectedStock.symbol}
</h3>
<span className="ml-2 text-gray-600">
{selectedStock.name}
</span>
</div>
<div className="flex items-center mt-1">
<span className="text-2xl font-bold text-gray-900">
{formatCurrency(selectedStock.price)}
</span>
<span
className={`ml-2 text-sm font-medium ${selectedStock.change >= 0 ? "text-green-600" : "text-red-600"}`}
>
{selectedStock.change >= 0 ? "▲" : "▼"}{" "}
{formatPercent(selectedStock.change)}
</span>
</div>
</div>
<button
onClick={() => setSelectedHolding(null)}
className="bg-gray-100 hover:bg-gray-200 p-1 rounded-md"
>
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
<div className="border-t border-gray-200 p-4">
<div className="h-40 bg-white">
<div className="flex items-end h-full space-x-1">
{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 (
<div
key={index}
className="flex flex-col items-center flex-1"
>
<div
className={`w-full rounded-sm ${point.price >= chartData[Math.max(0, index - 1)].price ? "bg-green-500" : "bg-red-500"}`}
style={{ height: `${heightPercent}%` }}
></div>
{index % 5 === 0 && (
<span className="text-xs text-gray-500 mt-1">
{point.date}
</span>
)}
</div>
);
})}
</div>
</div>
</div>
<div className="border-t border-gray-200 p-4">
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-xs text-gray-500">Shares Owned</p>
<p className="text-sm font-medium">
{selectedStock.shares.toLocaleString()}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Market Value</p>
<p className="text-sm font-medium">
{formatCurrency(selectedStock.value)}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Avg. Cost</p>
<p className="text-sm font-medium">
{formatCurrency(selectedStock.avgCost)}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Cost Basis</p>
<p className="text-sm font-medium">
{formatCurrency(
selectedStock.avgCost * selectedStock.shares,
)}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Gain/Loss</p>
<p
className={`text-sm font-medium ${selectedStock.price - selectedStock.avgCost >= 0 ? "text-green-600" : "text-red-600"}`}
>
{formatCurrency(
(selectedStock.price - selectedStock.avgCost) *
selectedStock.shares,
)}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Allocation</p>
<p className="text-sm font-medium">
{selectedStock.allocation.toFixed(2)}%
</p>
</div>
</div>
</div>
<div className="border-t border-gray-200 p-4 flex space-x-2">
<button className="flex-1 bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-4 rounded-md transition-colors text-sm">
Buy More
</button>
<button className="flex-1 bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md transition-colors text-sm">
Sell
</button>
<button className="flex items-center justify-center w-10 h-10 border border-gray-300 rounded-md hover:bg-gray-100 transition-colors">
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
</button>
</div>
</div>
)}
{activeTab === "performance" && (
<div className="space-y-6">
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg
className="w-5 h-5 mr-2 text-indigo-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"></path>
</svg>
Performance Overview
</h3>
<div className="grid grid-cols-4 gap-4">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-sm font-medium">Daily</p>
<p
className={`text-lg font-bold flex items-center ${portfolio.performance.daily >= 0 ? "text-green-600" : "text-red-600"}`}
>
{portfolio.performance.daily >= 0 ? (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(portfolio.performance.daily)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-sm font-medium">Weekly</p>
<p
className={`text-lg font-bold flex items-center ${portfolio.performance.weekly >= 0 ? "text-green-600" : "text-red-600"}`}
>
{portfolio.performance.weekly >= 0 ? (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(portfolio.performance.weekly)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-sm font-medium">Monthly</p>
<p
className={`text-lg font-bold flex items-center ${portfolio.performance.monthly >= 0 ? "text-green-600" : "text-red-600"}`}
>
{portfolio.performance.monthly >= 0 ? (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(portfolio.performance.monthly)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-sm font-medium">Yearly</p>
<p
className={`text-lg font-bold flex items-center ${portfolio.performance.yearly >= 0 ? "text-green-600" : "text-red-600"}`}
>
{portfolio.performance.yearly >= 0 ? (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(portfolio.performance.yearly)}
</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg
className="w-5 h-5 mr-2 text-indigo-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"></path>
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"></path>
</svg>
Portfolio Allocation
</h3>
<div className="space-y-3">
{sortedHoldings.map((holding) => (
<div
key={holding.symbol}
className="flex items-center group hover:bg-indigo-50 p-2 rounded-lg transition-colors"
>
<div className="w-24 text-sm font-medium text-indigo-600 flex items-center">
<div
className={`w-3 h-3 rounded-full mr-2 ${holding.change >= 0 ? "bg-green-500" : "bg-red-500"}`}
></div>
{holding.symbol}
</div>
<div className="flex-grow">
<div className="bg-gray-200 h-4 rounded-full overflow-hidden shadow-inner">
<div
className="h-4 bg-gradient-to-r from-indigo-500 to-indigo-600"
style={{ width: `${holding.allocation}%` }}
></div>
</div>
</div>
<div className="w-16 text-sm font-medium text-gray-900 text-right ml-3">
{holding.allocation.toFixed(1)}%
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity ml-2">
<button className="p-1 text-gray-400 hover:text-indigo-600">
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path
fillRule="evenodd"
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
</div>
))}
</div>
<div className="mt-6 bg-gray-50 p-3 rounded-lg">
<h4 className="text-sm font-medium text-gray-700 mb-2">
Portfolio Diversification
</h4>
<div className="flex h-4 rounded-full overflow-hidden">
{[
"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 (
<div
key={sector}
className={`${colors[index]} h-full`}
style={{ width: `${widths[index]}%` }}
title={`${sector}: ${widths[index]}%`}
></div>
);
})}
</div>
<div className="flex flex-wrap mt-2 text-xs">
{[
"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 (
<div key={sector} className="mr-3 flex items-center">
<div
className={`w-2 h-2 rounded-full ${colors[index].replace("text", "bg")} mr-1`}
></div>
<span className={`${colors[index]} font-medium`}>
{sector} {widths[index]}%
</span>
</div>
);
})}
</div>
</div>
</div>
<div className="flex justify-end space-x-2">
<button className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 shadow-sm flex items-center">
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
></path>
</svg>
Export Data
</button>
<button className="px-4 py-2 bg-indigo-600 border border-indigo-600 rounded-md text-sm font-medium text-white hover:bg-indigo-700 shadow-sm flex items-center">
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
></path>
</svg>
View Full Report
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -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<HTMLInputElement>) => {
const value = parseInt(e.target.value);
if (value > 0 && value <= 1000) {
setQuantity(value);
}
};
const handleLimitPriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="w-full max-w-md bg-white rounded-xl shadow-lg overflow-hidden border border-gray-200">
<div
className={`px-4 py-3 ${stockData.change >= 0 ? "bg-gradient-to-r from-green-600 to-green-500" : "bg-gradient-to-r from-red-600 to-red-500"}`}
>
<div className="flex justify-between items-center">
<div className="flex items-center">
{props.logo && (
<img
src={props.logo}
alt="Logo"
className="h-6 w-6 mr-2 rounded-full bg-white p-0.5"
/>
)}
<h3 className="text-white font-bold">
{stockData.symbol}{" "}
<span className="font-normal text-white/80 text-sm">
{stockData.name}
</span>
</h3>
</div>
<div className="flex items-center">
<span className="text-white font-bold text-xl mr-2">
{formatCurrency(stockData.price)}
</span>
<div
className={`flex items-center px-2 py-1 rounded text-xs font-medium text-white ${stockData.change >= 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)})
</div>
</div>
</div>
<div className="flex justify-between items-center text-xs text-white/80 mt-1">
<span className="italic">{props.instruction}</span>
<button
onClick={toggleLiveUpdates}
className={`flex items-center text-xs px-2 py-0.5 rounded ${isLiveUpdating ? "bg-white/30 text-white" : "bg-white/10 text-white/70"} hover:bg-white/20 transition-colors`}
>
<span
className={`inline-block w-2 h-2 rounded-full mr-1 ${isLiveUpdating ? "bg-green-400 animate-pulse" : "bg-gray-400"}`}
></span>
{isLiveUpdating ? "Live" : "Static"}
</button>
</div>
</div>
<div className="border-b border-gray-200">
<div className="flex">
<button
onClick={() => setActiveTab("chart")}
className={`px-4 py-2 text-sm font-medium ${
activeTab === "chart"
? "text-indigo-600 border-b-2 border-indigo-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Chart
</button>
<button
onClick={() => setActiveTab("details")}
className={`px-4 py-2 text-sm font-medium ${
activeTab === "details"
? "text-indigo-600 border-b-2 border-indigo-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Details
</button>
<button
onClick={() => setActiveTab("analysis")}
className={`px-4 py-2 text-sm font-medium ${
activeTab === "analysis"
? "text-indigo-600 border-b-2 border-indigo-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Analysis
</button>
</div>
</div>
<div className="p-4">
{activeTab === "chart" && (
<>
<div className="mb-4">
<div className="border rounded-lg p-3 bg-white shadow-sm">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span className="font-medium">Price Chart</span>
<div className="flex space-x-1">
<button className="px-1.5 py-0.5 bg-indigo-100 text-indigo-700 rounded text-xs font-medium">
1D
</button>
<button className="px-1.5 py-0.5 hover:bg-gray-100 rounded text-xs text-gray-600">
1W
</button>
<button className="px-1.5 py-0.5 hover:bg-gray-100 rounded text-xs text-gray-600">
1M
</button>
<button className="px-1.5 py-0.5 hover:bg-gray-100 rounded text-xs text-gray-600">
1Y
</button>
</div>
</div>
<div className="relative h-40 mt-2">
<div className="absolute inset-0">
{/* Chart grid lines */}
<div className="h-full flex flex-col justify-between">
{[0, 1, 2, 3].map((line) => (
<div
key={line}
className="border-t border-gray-100 w-full h-0"
></div>
))}
</div>
{/* SVG Line Chart */}
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{/* Chart path */}
<path
d={chartPath}
stroke={stockData.change >= 0 ? "#10b981" : "#ef4444"}
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
/>
{/* Gradient area under the chart */}
<path
d={`${chartPath} L 100,100 L 0,100 Z`}
fill={
stockData.change >= 0
? "url(#greenGradient)"
: "url(#redGradient)"
}
fillOpacity="0.2"
/>
{/* Gradient definitions */}
<defs>
<linearGradient
id="greenGradient"
x1="0%"
y1="0%"
x2="0%"
y2="100%"
>
<stop
offset="0%"
stopColor="#10b981"
stopOpacity="0.8"
/>
<stop
offset="100%"
stopColor="#10b981"
stopOpacity="0"
/>
</linearGradient>
<linearGradient
id="redGradient"
x1="0%"
y1="0%"
x2="0%"
y2="100%"
>
<stop
offset="0%"
stopColor="#ef4444"
stopOpacity="0.8"
/>
<stop
offset="100%"
stopColor="#ef4444"
stopOpacity="0"
/>
</linearGradient>
</defs>
</svg>
{/* Price labels on Y-axis */}
<div className="absolute right-0 inset-y-0 flex flex-col justify-between text-right pr-1 text-xs text-gray-400 pointer-events-none">
<span>{formatCurrency(max)}</span>
<span>{formatCurrency(min + range * 0.66)}</span>
<span>{formatCurrency(min + range * 0.33)}</span>
<span>{formatCurrency(min)}</span>
</div>
</div>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-2">
<div className="flex items-center">
<span className="inline-block w-2 h-2 bg-gray-300 rounded-full mr-1"></span>
{priceHistory[0]?.time || "9:30 AM"}
</div>
<div className="flex items-center">
<span>Vol: {stockData.volume.toLocaleString()}</span>
</div>
<div className="flex items-center">
<span className="inline-block w-2 h-2 bg-gray-300 rounded-full mr-1"></span>
{priceHistory[priceHistory.length - 1]?.time || "4:00 PM"}
</div>
</div>
</div>
</div>
{/* Key Stock Information */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="bg-gray-50 rounded-lg p-2.5 shadow-sm border border-gray-100">
<p className="text-xs text-gray-500 font-medium">Day Range</p>
<div className="relative pt-1">
<div className="overflow-hidden h-1.5 mb-1 text-xs flex rounded bg-gray-200 mt-1">
<div
className={`shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center ${stockData.change >= 0 ? "bg-green-500" : "bg-red-500"}`}
style={{
width: `${((stockData.price - stockData.dayLow) / (stockData.dayHigh - stockData.dayLow)) * 100}%`,
}}
></div>
</div>
<div className="flex justify-between text-xs text-gray-600">
<span>{formatCurrency(stockData.dayLow)}</span>
<span>{formatCurrency(stockData.dayHigh)}</span>
</div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-2.5 shadow-sm border border-gray-100">
<p className="text-xs text-gray-500 font-medium">
52-Week Range
</p>
<div className="relative pt-1">
<div className="overflow-hidden h-1.5 mb-1 text-xs flex rounded bg-gray-200 mt-1">
<div
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-indigo-500"
style={{
width: `${((stockData.price - stockData.fiftyTwoWeekLow) / (stockData.fiftyTwoWeekHigh - stockData.fiftyTwoWeekLow)) * 100}%`,
}}
></div>
</div>
<div className="flex justify-between text-xs text-gray-600">
<span>{formatCurrency(stockData.fiftyTwoWeekLow)}</span>
<span>{formatCurrency(stockData.fiftyTwoWeekHigh)}</span>
</div>
</div>
</div>
</div>
</>
)}
{activeTab === "details" && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100 shadow-sm">
<p className="text-xs text-gray-500 font-medium mb-1">Open</p>
<p className="font-medium">{formatCurrency(stockData.open)}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100 shadow-sm">
<p className="text-xs text-gray-500 font-medium mb-1">
Previous Close
</p>
<p className="font-medium">
{formatCurrency(stockData.previousClose)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100 shadow-sm">
<p className="text-xs text-gray-500 font-medium mb-1">
Market Cap
</p>
<p className="font-medium">{stockData.marketCap}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100 shadow-sm">
<p className="text-xs text-gray-500 font-medium mb-1">Volume</p>
<p className="font-medium">
{stockData.volume.toLocaleString()}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100 shadow-sm">
<p className="text-xs text-gray-500 font-medium mb-1">
P/E Ratio
</p>
<p className="font-medium">{stockData.peRatio}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100 shadow-sm">
<p className="text-xs text-gray-500 font-medium mb-1">
Dividend Yield
</p>
<p className="font-medium">{stockData.dividendYield}%</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100 shadow-sm">
<p className="text-xs text-gray-500 font-medium mb-1">
50-Day Avg
</p>
<p className="font-medium">
{formatCurrency(stockData.moving50Day)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100 shadow-sm">
<p className="text-xs text-gray-500 font-medium mb-1">
200-Day Avg
</p>
<p className="font-medium">
{formatCurrency(stockData.moving200Day)}
</p>
</div>
</div>
<div className="bg-indigo-50 rounded-lg p-4 border border-indigo-100">
<h4 className="font-medium text-indigo-900 text-sm mb-2">
Company Overview
</h4>
<p className="text-xs text-indigo-700">
{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.
</p>
</div>
</div>
)}
{activeTab === "analysis" && (
<div className="space-y-4">
<div className="bg-white rounded-lg p-3 border border-gray-200 shadow-sm">
<div className="flex justify-between items-center mb-2">
<h4 className="font-medium text-gray-900">Analyst Consensus</h4>
<div
className={`px-2 py-0.5 rounded text-xs font-medium ${
stockData.analystRating === "Buy"
? "bg-green-100 text-green-800"
: stockData.analystRating === "Hold"
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
>
{stockData.analystRating}
</div>
</div>
<div className="flex mb-1">
<div className="w-20 text-xs text-gray-500">Strong Buy</div>
<div className="flex-grow">
<div className="h-4 bg-gray-200 rounded-sm overflow-hidden">
<div
className="h-4 bg-green-600"
style={{ width: "65%" }}
></div>
</div>
</div>
<div className="w-8 text-xs text-right text-gray-600">65%</div>
</div>
<div className="flex mb-1">
<div className="w-20 text-xs text-gray-500">Buy</div>
<div className="flex-grow">
<div className="h-4 bg-gray-200 rounded-sm overflow-hidden">
<div
className="h-4 bg-green-500"
style={{ width: "20%" }}
></div>
</div>
</div>
<div className="w-8 text-xs text-right text-gray-600">20%</div>
</div>
<div className="flex mb-1">
<div className="w-20 text-xs text-gray-500">Hold</div>
<div className="flex-grow">
<div className="h-4 bg-gray-200 rounded-sm overflow-hidden">
<div
className="h-4 bg-yellow-500"
style={{ width: "10%" }}
></div>
</div>
</div>
<div className="w-8 text-xs text-right text-gray-600">10%</div>
</div>
<div className="flex mb-1">
<div className="w-20 text-xs text-gray-500">Sell</div>
<div className="flex-grow">
<div className="h-4 bg-gray-200 rounded-sm overflow-hidden">
<div
className="h-4 bg-red-500"
style={{ width: "5%" }}
></div>
</div>
</div>
<div className="w-8 text-xs text-right text-gray-600">5%</div>
</div>
<div className="mt-2 text-xs text-gray-500">
Based on {stockData.analystCount} analyst ratings
</div>
</div>
<div className="bg-white rounded-lg p-3 border border-gray-200 shadow-sm">
<div className="flex justify-between items-center mb-3">
<h4 className="font-medium text-gray-900">Price Target</h4>
<span className="text-sm font-bold text-indigo-600">
{formatCurrency(stockData.priceTarget)}
</span>
</div>
<div className="relative pt-1">
<div className="flex mb-2 items-center justify-between">
<div className="text-xs text-gray-500">
<span className="text-green-600 font-medium">
+
{(
(stockData.priceTarget / stockData.price - 1) *
100
).toFixed(2)}
%
</span>{" "}
Upside
</div>
</div>
<div className="overflow-hidden h-1.5 mb-1 text-xs flex rounded bg-gray-200">
<div
style={{
width: `${(stockData.price / stockData.priceTarget) * 100}%`,
}}
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-indigo-600"
></div>
</div>
<div className="flex justify-between text-xs text-gray-500">
<span>Current: {formatCurrency(stockData.price)}</span>
<span>Target: {formatCurrency(stockData.priceTarget)}</span>
</div>
</div>
</div>
<div className="bg-indigo-50 rounded-lg p-3 border border-indigo-100">
<h4 className="font-medium text-indigo-900 mb-2">Recent News</h4>
<div className="space-y-2">
<div className="text-xs text-indigo-800">
<p className="font-medium">
{stockData.name} Reports Strong Quarterly Earnings
</p>
<p className="text-indigo-600 text-xs">2 days ago</p>
</div>
<div className="text-xs text-indigo-800">
<p className="font-medium">
New Product Launch Expected Next Month
</p>
<p className="text-indigo-600 text-xs">5 days ago</p>
</div>
</div>
</div>
</div>
)}
{/* Order Interface */}
{!showOrder && !showOrderSuccess ? (
<div className="flex space-x-2 mt-4">
<button
onClick={() => {
setShowOrder(true);
setOrderType("buy");
}}
className="flex-1 bg-gradient-to-r from-green-600 to-green-500 hover:from-green-700 hover:to-green-600 text-white font-medium py-2 px-4 rounded-lg shadow-sm transition-all flex items-center justify-center"
>
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
></path>
</svg>
Buy
</button>
<button
onClick={() => {
setShowOrder(true);
setOrderType("sell");
}}
className="flex-1 bg-gradient-to-r from-red-600 to-red-500 hover:from-red-700 hover:to-red-600 text-white font-medium py-2 px-4 rounded-lg shadow-sm transition-all flex items-center justify-center"
>
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
clipRule="evenodd"
></path>
</svg>
Sell
</button>
</div>
) : showOrderSuccess ? (
<div className="mt-4 flex items-center justify-center bg-green-50 rounded-lg p-4 border border-green-200 text-green-800">
<svg
className="w-5 h-5 mr-2 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span className="font-medium">Order submitted successfully!</span>
</div>
) : (
<div className="mt-4 bg-white rounded-lg border border-gray-200 shadow-md p-4">
<div className="flex justify-between items-center mb-3">
<h4
className={`font-bold text-lg ${orderType === "buy" ? "text-green-600" : "text-red-600"}`}
>
{orderType === "buy" ? "Buy" : "Sell"} {stockData.symbol}
</h4>
<button
onClick={() => setShowOrder(false)}
className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full p-1 transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
<div className="mb-3">
<div className="flex space-x-1 mb-3">
{orderTypeOptions.map((option) => (
<button
key={option.value}
onClick={() => setSelectedOrderTypeOption(option.value)}
className={`flex-1 px-2 py-1.5 text-xs font-medium rounded-md transition-colors ${
selectedOrderTypeOption === option.value
? `${orderType === "buy" ? "bg-green-100 text-green-800 border-green-300" : "bg-red-100 text-red-800 border-red-300"} border`
: "bg-gray-100 text-gray-800 border border-gray-200 hover:bg-gray-200"
}`}
>
{option.label}
</button>
))}
</div>
<div className="flex space-x-2 mb-3">
<div className="flex-1">
<label
htmlFor="quantity"
className="block text-xs font-medium text-gray-700 mb-1"
>
Quantity
</label>
<div className="flex items-center">
<button
onClick={() => quantity > 1 && setQuantity(quantity - 1)}
className="border rounded-l px-2 py-1 text-gray-600 hover:bg-gray-100"
>
-
</button>
<input
type="number"
id="quantity"
min="1"
max="1000"
value={quantity}
onChange={handleQuantityChange}
className="border-t border-b w-full px-3 py-1 text-center focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<button
onClick={() =>
quantity < 1000 && setQuantity(quantity + 1)
}
className="border rounded-r px-2 py-1 text-gray-600 hover:bg-gray-100"
>
+
</button>
</div>
</div>
{selectedOrderTypeOption !== "market" && (
<div className="flex-1">
<label
htmlFor="limitPrice"
className="block text-xs font-medium text-gray-700 mb-1"
>
{selectedOrderTypeOption === "limit"
? "Limit Price"
: "Stop Price"}
</label>
<div className="flex items-center border rounded overflow-hidden">
<span className="bg-gray-100 px-2 py-1 text-gray-500 text-sm border-r">
$
</span>
<input
type="text"
id="limitPrice"
value={limitPrice}
onChange={handleLimitPriceChange}
className="flex-1 px-3 py-1 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
</div>
)}
</div>
{selectedOrderTypeOption !== "market" && (
<div className="rounded-md bg-indigo-50 p-2 mb-3">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-indigo-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-2">
<p className="text-xs text-indigo-700">
{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))}.`}
</p>
</div>
</div>
</div>
)}
<div className="bg-gray-50 rounded-md p-3 mb-4">
<div className="flex justify-between text-sm py-1">
<span className="text-gray-600">Market Price</span>
<span className="font-medium">
{formatCurrency(stockData.price)}
</span>
</div>
<div className="flex justify-between text-sm py-1">
<span className="text-gray-600">
{orderType === "buy" ? "Cost" : "Credit"} ({quantity}{" "}
{quantity === 1 ? "share" : "shares"})
</span>
<span>{formatCurrency(stockData.price * quantity)}</span>
</div>
<div className="flex justify-between text-sm py-1">
<span className="text-gray-600">Commission</span>
<span>$0.00</span>
</div>
<div className="flex justify-between text-sm py-1 border-t mt-1 pt-1">
<span className="font-medium">Estimated Total</span>
<span className="font-bold">
{formatCurrency(stockData.price * quantity)}
</span>
</div>
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => setShowOrder(false)}
className="flex-1 py-2 px-4 border border-gray-300 rounded-md text-gray-700 font-medium hover:bg-gray-50 text-sm transition-colors"
>
Cancel
</button>
<button
onClick={handleOrder}
disabled={!toolCallId}
className={`flex-1 py-2 px-4 rounded-md font-medium text-white text-sm ${
orderType === "buy"
? "bg-green-600 hover:bg-green-700"
: "bg-red-600 hover:bg-red-700"
} ${!toolCallId ? "opacity-50 cursor-not-allowed" : ""} transition-colors`}
>
Review {orderType === "buy" ? "Purchase" : "Sale"}
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -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<string | null>(null);
const selectedAccommodation = accommodations.find(
(acc) => acc.id === selectedId,
);
return (
<div className="w-full max-w-md bg-white rounded-lg shadow-md overflow-hidden">
<div className="bg-blue-600 px-4 py-3">
<div className="flex justify-between items-center">
<h3 className="text-white font-medium">
Accommodations in {tripDetails.location}
</h3>
{selectedId && (
<button
onClick={() => setSelectedId(null)}
className="text-white text-sm bg-blue-700 hover:bg-blue-800 px-2 py-1 rounded"
>
Back to list
</button>
)}
</div>
<p className="text-blue-100 text-xs">
{new Date(tripDetails.startDate).toLocaleDateString()} -{" "}
{new Date(tripDetails.endDate).toLocaleDateString()} ·{" "}
{tripDetails.numberOfGuests} guests
</p>
</div>
<div className="p-4">
{!selectedId ? (
<div className="space-y-3">
{accommodations.map((accommodation) => (
<div
key={accommodation.id}
onClick={() => 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"
}`}
>
<div className="w-20 h-20 bg-gray-200 rounded-md flex-shrink-0 overflow-hidden">
<img
src={accommodation.image}
alt={accommodation.name}
className="w-full h-full object-cover"
/>
</div>
<div className="ml-3 flex-1">
<div className="flex justify-between">
<h4 className="font-medium text-gray-900">
{accommodation.name}
</h4>
<span className="text-sm font-semibold text-blue-600">
{accommodation.price}
</span>
</div>
<p className="text-sm text-gray-500">{accommodation.type}</p>
<div className="flex items-center mt-1">
<svg
className="w-4 h-4 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
<span className="text-xs text-gray-500 ml-1">
{accommodation.rating}
</span>
</div>
{!accommodation.available && (
<span className="text-xs text-red-500 font-medium">
Unavailable for your dates
</span>
)}
</div>
</div>
))}
</div>
) : (
<div className="space-y-4">
{selectedAccommodation && (
<>
<div className="w-full h-40 bg-gray-200 rounded-lg overflow-hidden">
<img
src={selectedAccommodation.image}
alt={selectedAccommodation.name}
className="w-full h-full object-cover"
/>
</div>
<div>
<div className="flex justify-between items-start">
<h3 className="font-medium text-lg text-gray-900">
{selectedAccommodation.name}
</h3>
<span className="font-semibold text-blue-600">
{selectedAccommodation.price}
</span>
</div>
<div className="flex items-center mt-1">
<svg
className="w-4 h-4 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
<span className="text-sm text-gray-500 ml-1">
{selectedAccommodation.rating}
</span>
</div>
<p className="text-sm text-gray-600 mt-2">
Perfect accommodation in {tripDetails.location} for your{" "}
{tripDetails.numberOfGuests} guests.
</p>
<div className="mt-3">
<h4 className="text-sm font-medium text-gray-700">
Amenities:
</h4>
<div className="flex flex-wrap gap-1 mt-1">
{selectedAccommodation.amenities.map((amenity) => (
<span
key={amenity}
className="text-xs bg-gray-100 px-2 py-1 rounded"
>
{amenity}
</span>
))}
</div>
</div>
<button
className={`w-full mt-4 py-2 rounded-md text-white font-medium ${
selectedAccommodation.available
? "bg-blue-600 hover:bg-blue-700"
: "bg-gray-400 cursor-not-allowed"
}`}
disabled={!selectedAccommodation.available}
>
{selectedAccommodation.available
? "Book Now"
: "Unavailable"}
</button>
</div>
</>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -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<HTMLInputElement | HTMLTextAreaElement>,
) => {
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 (
<div className="w-full max-w-md bg-white rounded-lg shadow-md overflow-hidden">
<div className="bg-blue-600 px-4 py-3">
<h3 className="text-white font-medium">Book {accommodation.name}</h3>
<p className="text-blue-100 text-xs">
{new Date(tripDetails.startDate).toLocaleDateString()} -{" "}
{new Date(tripDetails.endDate).toLocaleDateString()} ·{" "}
{tripDetails.numberOfGuests} guests
</p>
</div>
<div className="p-4">
{bookingStep === "details" && (
<>
<div className="flex items-center space-x-3 mb-4">
<div className="flex-shrink-0 w-16 h-16 bg-gray-200 rounded-md overflow-hidden">
<img
src={accommodation.image}
alt={accommodation.name}
className="w-full h-full object-cover"
/>
</div>
<div>
<h4 className="font-medium text-gray-900">
{accommodation.name}
</h4>
<div className="flex items-center mt-1">
<svg
className="w-4 h-4 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
<span className="text-xs text-gray-500 ml-1">
{accommodation.rating}
</span>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-sm text-gray-500">
{accommodation.type}
</span>
<span className="text-sm font-semibold text-blue-600">
{accommodation.price}
</span>
</div>
</div>
</div>
<div className="border-t border-b py-3 mb-4">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Check-in</span>
<span className="font-medium">
{new Date(tripDetails.startDate).toLocaleDateString()} (
{accommodation.checkInTime})
</span>
</div>
<div className="flex justify-between text-sm mt-2">
<span className="text-gray-600">Check-out</span>
<span className="font-medium">
{new Date(tripDetails.endDate).toLocaleDateString()} (
{accommodation.checkOutTime})
</span>
</div>
<div className="flex justify-between text-sm mt-2">
<span className="text-gray-600">Guests</span>
<span className="font-medium">
{tripDetails.numberOfGuests}
</span>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Room Type
</label>
<div className="grid grid-cols-3 gap-2">
{accommodation.roomTypes.map((room) => (
<button
key={room}
type="button"
onClick={() => setSelectedRoom(room)}
className={`text-sm py-2 px-3 rounded-md border transition-colors ${
selectedRoom === room
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-gray-300 text-gray-700 hover:border-gray-400"
}`}
>
{room}
</button>
))}
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Full Name
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleInputChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<div>
<label
htmlFor="phone"
className="block text-sm font-medium text-gray-700 mb-1"
>
Phone
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleInputChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<div>
<label
htmlFor="specialRequests"
className="block text-sm font-medium text-gray-700 mb-1"
>
Special Requests
</label>
<textarea
id="specialRequests"
name="specialRequests"
value={formData.specialRequests}
onChange={handleInputChange}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<div className="border-t pt-3 mt-4">
<div className="flex justify-between items-center mb-3">
<span className="text-gray-600 text-sm">Total Price:</span>
<span className="font-semibold text-lg">
{accommodation.totalPrice}
</span>
</div>
<button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors"
>
Continue to Payment
</button>
</div>
</form>
</>
)}
{bookingStep === "payment" && (
<form onSubmit={handlePayment} className="space-y-3">
<h4 className="font-medium text-lg text-gray-900 mb-3">
Payment Details
</h4>
<div>
<label
htmlFor="cardName"
className="block text-sm font-medium text-gray-700 mb-1"
>
Name on Card
</label>
<input
type="text"
id="cardName"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm"
required
/>
</div>
<div>
<label
htmlFor="cardNumber"
className="block text-sm font-medium text-gray-700 mb-1"
>
Card Number
</label>
<input
type="text"
id="cardNumber"
placeholder="XXXX XXXX XXXX XXXX"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm"
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label
htmlFor="expiry"
className="block text-sm font-medium text-gray-700 mb-1"
>
Expiry Date
</label>
<input
type="text"
id="expiry"
placeholder="MM/YY"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm"
required
/>
</div>
<div>
<label
htmlFor="cvc"
className="block text-sm font-medium text-gray-700 mb-1"
>
CVC
</label>
<input
type="text"
id="cvc"
placeholder="XXX"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm"
required
/>
</div>
</div>
<div className="border-t pt-3 mt-4">
<div className="flex justify-between items-center mb-3">
<span className="text-gray-600 text-sm">Total Amount:</span>
<span className="font-semibold text-lg">
{accommodation.totalPrice}
</span>
</div>
<button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors"
>
Complete Booking
</button>
<button
type="button"
onClick={() => setBookingStep("details")}
className="w-full mt-2 bg-white border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded-md hover:bg-gray-50 transition-colors"
>
Back
</button>
</div>
</form>
)}
{bookingStep === "confirmed" && (
<div className="text-center py-6">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-3">
<svg
className="h-6 w-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900">
Booking Confirmed!
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
Your booking at {accommodation.name} has been confirmed. You'll
receive a confirmation email shortly at {formData.email}.
</p>
</div>
<div className="mt-4 p-3 bg-gray-50 rounded-lg text-left">
<h4 className="font-medium text-sm text-gray-700">
Booking Summary
</h4>
<ul className="mt-2 space-y-1 text-xs text-gray-600">
<li className="flex justify-between">
<span>Check-in:</span>
<span className="font-medium">
{new Date(tripDetails.startDate).toLocaleDateString()}
</span>
</li>
<li className="flex justify-between">
<span>Check-out:</span>
<span className="font-medium">
{new Date(tripDetails.endDate).toLocaleDateString()}
</span>
</li>
<li className="flex justify-between">
<span>Room type:</span>
<span className="font-medium">{selectedRoom}</span>
</li>
<li className="flex justify-between">
<span>Guests:</span>
<span className="font-medium">
{tripDetails.numberOfGuests}
</span>
</li>
<li className="flex justify-between pt-1 mt-1 border-t">
<span>Total paid:</span>
<span className="font-medium">
{accommodation.totalPrice}
</span>
</li>
</ul>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,350 @@
import "./index.css";
import { TripDetails } from "../../../trip-planner/types";
import { useState } from "react";
export default function BookRestaurant({
tripDetails,
restaurantName,
}: {
tripDetails: TripDetails;
restaurantName: string;
}) {
// Placeholder data - ideally would come from props
const [restaurant] = useState({
name: restaurantName,
cuisine: "Contemporary",
priceRange: "$$",
rating: 4.7,
image: "https://placehold.co/300x200?text=Restaurant",
openingHours: "5:00 PM - 10:00 PM",
address: "123 Main St, " + tripDetails.location,
availableTimes: ["6:00 PM", "7:00 PM", "8:00 PM", "9:00 PM"],
});
const [reservationStep, setReservationStep] = useState<
"selection" | "details" | "confirmed"
>("selection");
const [selectedDate, setSelectedDate] = useState<Date>(
new Date(tripDetails.startDate),
);
const [selectedTime, setSelectedTime] = useState<string | null>(null);
const [guests, setGuests] = useState(Math.min(tripDetails.numberOfGuests, 8));
const [formData, setFormData] = useState({
name: "",
email: "",
phone: "",
specialRequests: "",
});
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const date = new Date(e.target.value);
setSelectedDate(date);
};
const handleGuestsChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setGuests(Number(e.target.value));
};
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleTimeSelection = (time: string) => {
setSelectedTime(time);
};
const handleContinue = () => {
if (selectedTime) {
setReservationStep("details");
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setReservationStep("confirmed");
};
const formatDate = (date: Date) => {
return date.toISOString().split("T")[0];
};
return (
<div className="w-full max-w-md bg-white rounded-lg shadow-md overflow-hidden">
<div className="bg-orange-600 px-4 py-3">
<h3 className="text-white font-medium">Reserve at {restaurant.name}</h3>
<p className="text-orange-100 text-xs">
{restaurant.cuisine} {restaurant.priceRange} {restaurant.rating}
</p>
</div>
<div className="p-4">
{reservationStep === "selection" && (
<div className="space-y-4">
<div className="flex items-center space-x-3 mb-4">
<div className="flex-shrink-0 w-16 h-16 bg-gray-200 rounded-md overflow-hidden">
<img
src={restaurant.image}
alt={restaurant.name}
className="w-full h-full object-cover"
/>
</div>
<div>
<h4 className="font-medium text-gray-900">{restaurant.name}</h4>
<p className="text-sm text-gray-500">{restaurant.address}</p>
<p className="text-sm text-gray-500">
{restaurant.openingHours}
</p>
</div>
</div>
<div>
<label
htmlFor="date"
className="block text-sm font-medium text-gray-700 mb-1"
>
Date
</label>
<input
type="date"
id="date"
min={formatDate(new Date(tripDetails.startDate))}
max={formatDate(new Date(tripDetails.endDate))}
value={formatDate(selectedDate)}
onChange={handleDateChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500 text-sm"
/>
</div>
<div>
<label
htmlFor="guests"
className="block text-sm font-medium text-gray-700 mb-1"
>
Guests
</label>
<select
id="guests"
value={guests}
onChange={handleGuestsChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500 text-sm"
>
{Array.from({ length: 8 }, (_, i) => i + 1).map((num) => (
<option key={num} value={num}>
{num} {num === 1 ? "person" : "people"}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Available Times
</label>
<div className="grid grid-cols-3 gap-2">
{restaurant.availableTimes.map((time) => (
<button
key={time}
type="button"
onClick={() => handleTimeSelection(time)}
className={`text-sm py-2 px-3 rounded-md border transition-colors ${
selectedTime === time
? "border-orange-500 bg-orange-50 text-orange-700"
: "border-gray-300 text-gray-700 hover:border-gray-400"
}`}
>
{time}
</button>
))}
</div>
</div>
<button
onClick={handleContinue}
disabled={!selectedTime}
className={`w-full py-2 rounded-md text-white font-medium ${
selectedTime
? "bg-orange-600 hover:bg-orange-700"
: "bg-gray-400 cursor-not-allowed"
}`}
>
Continue
</button>
</div>
)}
{reservationStep === "details" && (
<form onSubmit={handleSubmit} className="space-y-3">
<div className="border-b pb-3 mb-1">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Date & Time</span>
<span className="font-medium">
{selectedDate.toLocaleDateString()} at {selectedTime}
</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-gray-600">Party Size</span>
<span className="font-medium">
{guests} {guests === 1 ? "person" : "people"}
</span>
</div>
<button
type="button"
onClick={() => setReservationStep("selection")}
className="text-orange-600 text-xs hover:underline mt-2"
>
Change
</button>
</div>
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Full Name
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleInputChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500 text-sm"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500 text-sm"
/>
</div>
<div>
<label
htmlFor="phone"
className="block text-sm font-medium text-gray-700 mb-1"
>
Phone
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleInputChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500 text-sm"
/>
</div>
<div>
<label
htmlFor="specialRequests"
className="block text-sm font-medium text-gray-700 mb-1"
>
Special Requests
</label>
<textarea
id="specialRequests"
name="specialRequests"
value={formData.specialRequests}
onChange={handleInputChange}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500 text-sm"
placeholder="Allergies, special occasions, seating preferences..."
/>
</div>
<div className="pt-2">
<button
type="submit"
className="w-full bg-orange-600 hover:bg-orange-700 text-white font-medium py-2 px-4 rounded-md transition-colors"
>
Confirm Reservation
</button>
</div>
</form>
)}
{reservationStep === "confirmed" && (
<div className="text-center py-6">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-3">
<svg
className="h-6 w-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900">
Reservation Confirmed!
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
Your table at {restaurant.name} has been reserved. You'll
receive a confirmation email shortly at {formData.email}.
</p>
</div>
<div className="mt-4 p-3 bg-gray-50 rounded-lg text-left">
<h4 className="font-medium text-sm text-gray-700">
Reservation Details
</h4>
<ul className="mt-2 space-y-1 text-xs text-gray-600">
<li className="flex justify-between">
<span>Restaurant:</span>
<span className="font-medium">{restaurant.name}</span>
</li>
<li className="flex justify-between">
<span>Date:</span>
<span className="font-medium">
{selectedDate.toLocaleDateString()}
</span>
</li>
<li className="flex justify-between">
<span>Time:</span>
<span className="font-medium">{selectedTime}</span>
</li>
<li className="flex justify-between">
<span>Party Size:</span>
<span className="font-medium">
{guests} {guests === 1 ? "person" : "people"}
</span>
</li>
<li className="flex justify-between">
<span>Reservation Name:</span>
<span className="font-medium">{formData.name}</span>
</li>
</ul>
<p className="mt-3 text-xs text-gray-500">
Need to cancel or modify? Please call the restaurant directly at
(123) 456-7890.
</p>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,250 @@
import "./index.css";
import { TripDetails } from "../../../trip-planner/types";
import { useState } from "react";
export default function RestaurantsList({
tripDetails,
}: {
tripDetails: TripDetails;
}) {
// Placeholder data - ideally would come from props
const [restaurants] = useState([
{
id: "1",
name: "The Local Grill",
cuisine: "Steakhouse",
priceRange: "$$",
rating: 4.7,
distance: "0.5 miles from center",
image: "https://placehold.co/300x200?text=Restaurant1",
openingHours: "5:00 PM - 10:00 PM",
popular: true,
},
{
id: "2",
name: "Ocean Breeze",
cuisine: "Seafood",
priceRange: "$$$",
rating: 4.9,
distance: "0.8 miles from center",
image: "https://placehold.co/300x200?text=Restaurant2",
openingHours: "12:00 PM - 11:00 PM",
popular: true,
},
{
id: "3",
name: "Pasta Paradise",
cuisine: "Italian",
priceRange: "$$",
rating: 4.5,
distance: "1.2 miles from center",
image: "https://placehold.co/300x200?text=Restaurant3",
openingHours: "11:30 AM - 9:30 PM",
popular: false,
},
{
id: "4",
name: "Spice Garden",
cuisine: "Indian",
priceRange: "$$",
rating: 4.6,
distance: "0.7 miles from center",
image: "https://placehold.co/300x200?text=Restaurant4",
openingHours: "12:00 PM - 10:00 PM",
popular: false,
},
]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filter, setFilter] = useState<string | null>(null);
const selectedRestaurant = restaurants.find((r) => r.id === selectedId);
const filteredRestaurants = filter
? restaurants.filter((r) => r.cuisine === filter)
: restaurants;
const cuisines = Array.from(new Set(restaurants.map((r) => r.cuisine)));
return (
<div className="w-full max-w-md bg-white rounded-lg shadow-md overflow-hidden">
<div className="bg-orange-600 px-4 py-3">
<div className="flex justify-between items-center">
<h3 className="text-white font-medium">
Restaurants in {tripDetails.location}
</h3>
{selectedId && (
<button
onClick={() => setSelectedId(null)}
className="text-white text-sm bg-orange-700 hover:bg-orange-800 px-2 py-1 rounded"
>
Back to list
</button>
)}
</div>
<p className="text-orange-100 text-xs">
For your trip {new Date(tripDetails.startDate).toLocaleDateString()} -{" "}
{new Date(tripDetails.endDate).toLocaleDateString()}
</p>
</div>
{!selectedId ? (
<div className="p-4">
<div className="mb-3">
<div className="flex flex-wrap gap-1 mb-1">
<button
onClick={() => setFilter(null)}
className={`px-2 py-1 text-xs rounded-full ${
filter === null
? "bg-orange-600 text-white"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
All
</button>
{cuisines.map((cuisine) => (
<button
key={cuisine}
onClick={() => setFilter(cuisine)}
className={`px-2 py-1 text-xs rounded-full ${
filter === cuisine
? "bg-orange-600 text-white"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
{cuisine}
</button>
))}
</div>
<p className="text-xs text-gray-500">
Showing {filteredRestaurants.length} restaurants{" "}
{filter ? `in ${filter}` : ""}
</p>
</div>
<div className="space-y-3">
{filteredRestaurants.map((restaurant) => (
<div
key={restaurant.id}
onClick={() => setSelectedId(restaurant.id)}
className="border rounded-lg p-3 cursor-pointer hover:border-orange-300 hover:shadow-md transition-all"
>
<div className="flex">
<div className="w-20 h-20 bg-gray-200 rounded-md flex-shrink-0 overflow-hidden">
<img
src={restaurant.image}
alt={restaurant.name}
className="w-full h-full object-cover"
/>
</div>
<div className="ml-3 flex-1">
<div className="flex justify-between items-start">
<div>
<h4 className="font-medium text-gray-900">
{restaurant.name}
</h4>
<p className="text-sm text-gray-500">
{restaurant.cuisine}
</p>
</div>
<span className="text-sm text-gray-700">
{restaurant.priceRange}
</span>
</div>
<div className="flex items-center mt-1">
<svg
className="w-4 h-4 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
<span className="text-xs text-gray-500 ml-1">
{restaurant.rating}
</span>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-xs text-gray-500">
{restaurant.distance}
</span>
{restaurant.popular && (
<span className="text-xs bg-orange-100 text-orange-800 px-1.5 py-0.5 rounded-sm">
Popular
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="p-4">
{selectedRestaurant && (
<div className="space-y-4">
<div className="w-full h-40 bg-gray-200 rounded-lg overflow-hidden">
<img
src={selectedRestaurant.image}
alt={selectedRestaurant.name}
className="w-full h-full object-cover"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium text-lg text-gray-900">
{selectedRestaurant.name}
</h3>
<p className="text-sm text-gray-600">
{selectedRestaurant.cuisine}
</p>
</div>
<span className="text-gray-700 font-medium">
{selectedRestaurant.priceRange}
</span>
</div>
<div className="flex items-center">
<svg
className="w-4 h-4 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
<span className="text-sm text-gray-600 ml-1">
{selectedRestaurant.rating} rating
</span>
</div>
<div className="flex items-center text-sm text-gray-600 space-x-4">
<span>{selectedRestaurant.distance}</span>
<span></span>
<span>{selectedRestaurant.openingHours}</span>
</div>
<p className="text-sm text-gray-600 pt-2 border-t">
{selectedRestaurant.name} offers a wonderful dining experience
in {tripDetails.location}. Perfect for a group of{" "}
{tripDetails.numberOfGuests} guests. Enjoy authentic{" "}
{selectedRestaurant.cuisine} cuisine in a relaxed atmosphere.
</p>
<div className="pt-3 flex flex-col space-y-2">
<button className="w-full bg-orange-600 hover:bg-orange-700 text-white font-medium py-2 px-4 rounded-md transition-colors">
Reserve a Table
</button>
<button className="w-full bg-white border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded-md hover:bg-gray-50 transition-colors">
View Menu
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { BaseMessage } from "@langchain/core/messages";
export function formatMessages(messages: BaseMessage[]): string {
return messages
.map((m, i) => {
const role = m.getType();
const contentString =
typeof m.content === "string" ? m.content : JSON.stringify(m.content);
return `<${role} index="${i}">\n${contentString}\n</${role}>`;
})
.join("\n");
}

View File

@@ -36,9 +36,11 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.0.1", "react-markdown": "^10.0.1",
"react-router-dom": "^6.17.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"use-query-params": "^2.2.1",
"uuid": "^11.0.5", "uuid": "^11.0.5",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },

78
pnpm-lock.yaml generated
View File

@@ -84,6 +84,9 @@ importers:
react-markdown: react-markdown:
specifier: ^10.0.1 specifier: ^10.0.1
version: 10.0.1(@types/react@19.0.10)(react@19.0.0) version: 10.0.1(@types/react@19.0.10)(react@19.0.0)
react-router-dom:
specifier: ^6.17.0
version: 6.30.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
remark-gfm: remark-gfm:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -93,6 +96,9 @@ importers:
tailwindcss-animate: tailwindcss-animate:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.0.9) version: 1.0.7(tailwindcss@4.0.9)
use-query-params:
specifier: ^2.2.1
version: 2.2.1(react-dom@19.0.0(react@19.0.0))(react-router-dom@6.30.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
uuid: uuid:
specifier: ^11.0.5 specifier: ^11.0.5
version: 11.1.0 version: 11.1.0
@@ -1420,6 +1426,13 @@ packages:
integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==, integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==,
} }
"@remix-run/router@1.23.0":
resolution:
{
integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==,
}
engines: { node: ">=14.0.0" }
"@rollup/rollup-android-arm-eabi@4.34.8": "@rollup/rollup-android-arm-eabi@4.34.8":
resolution: resolution:
{ {
@@ -4193,6 +4206,25 @@ packages:
"@types/react": "@types/react":
optional: true optional: true
react-router-dom@6.30.0:
resolution:
{
integrity: sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==,
}
engines: { node: ">=14.0.0" }
peerDependencies:
react: ">=16.8"
react-dom: ">=16.8"
react-router@6.30.0:
resolution:
{
integrity: sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==,
}
engines: { node: ">=14.0.0" }
peerDependencies:
react: ">=16.8"
react-style-singleton@2.2.3: react-style-singleton@2.2.3:
resolution: resolution:
{ {
@@ -4361,6 +4393,12 @@ packages:
engines: { node: ">=10" } engines: { node: ">=10" }
hasBin: true hasBin: true
serialize-query-params@2.0.2:
resolution:
{
integrity: sha512-1chMo1dST4pFA9RDXAtF0Rbjaut4is7bzFbI1Z26IuMub68pNCILku85aYmeFhvnY//BXUPUhoRMjYcsT93J/Q==,
}
shebang-command@2.0.0: shebang-command@2.0.0:
resolution: resolution:
{ {
@@ -4746,6 +4784,22 @@ packages:
"@types/react": "@types/react":
optional: true optional: true
use-query-params@2.2.1:
resolution:
{
integrity: sha512-i6alcyLB8w9i3ZK3caNftdb+UnbfBRNPDnc89CNQWkGRmDrm/gfydHvMBfVsQJRq3NoHOM2dt/ceBWG2397v1Q==,
}
peerDependencies:
"@reach/router": ^1.2.1
react: ">=16.8.0"
react-dom: ">=16.8.0"
react-router-dom: ">=5"
peerDependenciesMeta:
"@reach/router":
optional: true
react-router-dom:
optional: true
use-sidecar@1.1.3: use-sidecar@1.1.3:
resolution: resolution:
{ {
@@ -5797,6 +5851,8 @@ snapshots:
"@radix-ui/rect@1.1.0": {} "@radix-ui/rect@1.1.0": {}
"@remix-run/router@1.23.0": {}
"@rollup/rollup-android-arm-eabi@4.34.8": "@rollup/rollup-android-arm-eabi@4.34.8":
optional: true optional: true
@@ -7583,6 +7639,18 @@ snapshots:
optionalDependencies: optionalDependencies:
"@types/react": 19.0.10 "@types/react": 19.0.10
react-router-dom@6.30.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
"@remix-run/router": 1.23.0
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-router: 6.30.0(react@19.0.0)
react-router@6.30.0(react@19.0.0):
dependencies:
"@remix-run/router": 1.23.0
react: 19.0.0
react-style-singleton@2.2.3(@types/react@19.0.10)(react@19.0.0): react-style-singleton@2.2.3(@types/react@19.0.10)(react@19.0.0):
dependencies: dependencies:
get-nonce: 1.0.1 get-nonce: 1.0.1
@@ -7701,6 +7769,8 @@ snapshots:
semver@7.7.1: {} semver@7.7.1: {}
serialize-query-params@2.0.2: {}
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
shebang-regex: 3.0.0 shebang-regex: 3.0.0
@@ -7911,6 +7981,14 @@ snapshots:
optionalDependencies: optionalDependencies:
"@types/react": 19.0.10 "@types/react": 19.0.10
use-query-params@2.2.1(react-dom@19.0.0(react@19.0.0))(react-router-dom@6.30.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
dependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
serialize-query-params: 2.0.2
optionalDependencies:
react-router-dom: 6.30.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
use-sidecar@1.1.3(@types/react@19.0.10)(react@19.0.0): use-sidecar@1.1.3(@types/react@19.0.10)(react@19.0.0):
dependencies: dependencies:
detect-node-es: 1.1.0 detect-node-es: 1.1.0

View File

@@ -13,23 +13,9 @@ import {
ensureToolCallsHaveResponses, ensureToolCallsHaveResponses,
} from "@/lib/ensure-tool-responses"; } from "@/lib/ensure-tool-responses";
import { LangGraphLogoSVG } from "../icons/langgraph"; import { LangGraphLogoSVG } from "../icons/langgraph";
import { TooltipIconButton } from "./tooltip-icon-button";
// const dummyMessages = [ import { SquarePen } from "lucide-react";
// { type: "human", content: "Hi! What can you do?" }, import { StringParam, useQueryParam } from "use-query-params";
// {
// type: "ai",
// content: `Hello! I can assist you with a variety of tasks, including:
// 1. **Answering Questions**: I can provide information on a wide range of topics, from science and history to technology and culture.
// 2. **Writing Assistance**: I can help you draft emails, essays, reports, and creative writing pieces.
// 3. **Learning Support**: I can explain concepts, help with homework, and provide study tips.
// 4. **Language Help**: I can assist with translations, grammar, and vocabulary in multiple languages.
// 5. **Recommendations**: I can suggest books, movies, recipes, and more based on your interests.
// 6. **General Advice**: I can offer tips on various subjects, including productivity, wellness, and personal development.
// If you have something specific in mind, feel free to ask!`,
// },
// ];
function Title({ className }: { className?: string }) { function Title({ className }: { className?: string }) {
return ( return (
@@ -40,11 +26,26 @@ function Title({ className }: { className?: string }) {
); );
} }
function NewThread() {
const [_, setThreadId] = useQueryParam("threadId", StringParam);
return (
<TooltipIconButton
size="lg"
className="p-4"
tooltip="New thread"
variant="ghost"
onClick={() => setThreadId(null)}
>
<SquarePen className="size-5" />
</TooltipIconButton>
);
}
export function Thread() { export function Thread() {
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [firstTokenReceived, setFirstTokenReceived] = useState(false); const [firstTokenReceived, setFirstTokenReceived] = useState(false);
const stream = useStreamContext(); const stream = useStreamContext();
const messages = stream.messages; const messages = stream.messages;
const isLoading = stream.isLoading; const isLoading = stream.isLoading;
const prevMessageLength = useRef(0); const prevMessageLength = useRef(0);
@@ -111,7 +112,8 @@ export function Thread() {
</div> </div>
)} )}
{chatStarted && ( {chatStarted && (
<div className="hidden md:flex absolute top-4 right-4"> <div className="hidden md:flex items-center gap-3 absolute top-4 right-4">
<NewThread />
<Title /> <Title />
</div> </div>
)} )}

View File

@@ -49,12 +49,12 @@ export function AssistantMessage({
isLoading: boolean; isLoading: boolean;
handleRegenerate: (parentCheckpoint: Checkpoint | null | undefined) => void; handleRegenerate: (parentCheckpoint: Checkpoint | null | undefined) => void;
}) { }) {
const contentString = getContentString(message.content);
const thread = useStreamContext(); const thread = useStreamContext();
const meta = thread.getMessagesMetadata(message); const meta = thread.getMessagesMetadata(message);
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint; const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
const contentString = getContentString(message.content);
return ( return (
<div className="flex items-start mr-auto gap-2 group"> <div className="flex items-start mr-auto gap-2 group">
<Avatar> <Avatar>

View File

@@ -2,9 +2,16 @@ import { createRoot } from "react-dom/client";
import "./index.css"; import "./index.css";
import App from "./App.tsx"; import App from "./App.tsx";
import { StreamProvider } from "./providers/Stream.tsx"; import { StreamProvider } from "./providers/Stream.tsx";
import { QueryParamProvider } from "use-query-params";
import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6";
import { BrowserRouter } from "react-router-dom";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StreamProvider> <BrowserRouter>
<App /> <QueryParamProvider adapter={ReactRouter6Adapter}>
</StreamProvider>, <StreamProvider>
<App />
</StreamProvider>
</QueryParamProvider>
</BrowserRouter>,
); );

View File

@@ -1,10 +1,11 @@
import React, { createContext, useContext, ReactNode } from "react"; import React, { createContext, useContext, ReactNode } from "react";
import { useStream } from "@langchain/langgraph-sdk/react"; import { useStream } from "@langchain/langgraph-sdk/react";
import type { Message } from "@langchain/langgraph-sdk"; import { type Message } from "@langchain/langgraph-sdk";
import type { import type {
UIMessage, UIMessage,
RemoveUIMessage, RemoveUIMessage,
} from "@langchain/langgraph-sdk/react-ui/types"; } from "@langchain/langgraph-sdk/react-ui/types";
import { useQueryParam, StringParam } from "use-query-params";
const useTypedStream = useStream< const useTypedStream = useStream<
{ messages: Message[]; ui: UIMessage[] }, { messages: Message[]; ui: UIMessage[] },
@@ -23,11 +24,18 @@ const StreamContext = createContext<StreamContextType | undefined>(undefined);
export const StreamProvider: React.FC<{ children: ReactNode }> = ({ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
children, children,
}) => { }) => {
const [threadId, setThreadId] = useQueryParam("threadId", StringParam);
const streamValue = useTypedStream({ const streamValue = useTypedStream({
apiUrl: "http://localhost:2024", apiUrl: "http://localhost:2024",
assistantId: "agent", assistantId: "agent",
threadId: threadId ?? null,
onThreadId: setThreadId,
}); });
console.log("threadId", threadId);
console.log("streamValue", streamValue.values);
return ( return (
<StreamContext.Provider value={streamValue}> <StreamContext.Provider value={streamValue}>
{children} {children}