Merge branch 'main' into brace/fix-branching
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
51
agent/trip-planner/index.tsx
Normal file
51
agent/trip-planner/index.tsx
Normal 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";
|
||||||
70
agent/trip-planner/nodes/classify.ts
Normal file
70
agent/trip-planner/nodes/classify.ts
Normal 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 {};
|
||||||
|
}
|
||||||
123
agent/trip-planner/nodes/extraction.tsx
Normal file
123
agent/trip-planner/nodes/extraction.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
118
agent/trip-planner/nodes/tools.tsx
Normal file
118
agent/trip-planner/nodes/tools.tsx
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
18
agent/trip-planner/types.ts
Normal file
18
agent/trip-planner/types.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
959
agent/uis/stockbroker/portfolio-view/index.tsx
Normal file
959
agent/uis/stockbroker/portfolio-view/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
953
agent/uis/stockbroker/stock-price/index.tsx
Normal file
953
agent/uis/stockbroker/stock-price/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
agent/uis/trip-planner/accommodations-list/index.css
Normal file
1
agent/uis/trip-planner/accommodations-list/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
195
agent/uis/trip-planner/accommodations-list/index.tsx
Normal file
195
agent/uis/trip-planner/accommodations-list/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
agent/uis/trip-planner/book-accommodation/index.css
Normal file
1
agent/uis/trip-planner/book-accommodation/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
403
agent/uis/trip-planner/book-accommodation/index.tsx
Normal file
403
agent/uis/trip-planner/book-accommodation/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
agent/uis/trip-planner/book-restaurant/index.css
Normal file
1
agent/uis/trip-planner/book-restaurant/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
350
agent/uis/trip-planner/book-restaurant/index.tsx
Normal file
350
agent/uis/trip-planner/book-restaurant/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
agent/uis/trip-planner/restaurants-list/index.css
Normal file
1
agent/uis/trip-planner/restaurants-list/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
250
agent/uis/trip-planner/restaurants-list/index.tsx
Normal file
250
agent/uis/trip-planner/restaurants-list/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
agent/utils/format-messages.ts
Normal file
12
agent/utils/format-messages.ts
Normal 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");
|
||||||
|
}
|
||||||
@@ -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
78
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<BrowserRouter>
|
||||||
|
<QueryParamProvider adapter={ReactRouter6Adapter}>
|
||||||
<StreamProvider>
|
<StreamProvider>
|
||||||
<App />
|
<App />
|
||||||
</StreamProvider>,
|
</StreamProvider>
|
||||||
|
</QueryParamProvider>
|
||||||
|
</BrowserRouter>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user