Merge pull request #25 from langchain-ai/brace/fix-tool-calls
fix: Tool calls for trip planner
This commit is contained in:
@@ -10,5 +10,5 @@ interface ToolCall {
|
|||||||
export function findToolCall<Name extends string>(name: Name) {
|
export function findToolCall<Name extends string>(name: Name) {
|
||||||
return <Args extends ZodTypeAny>(
|
return <Args extends ZodTypeAny>(
|
||||||
x: ToolCall,
|
x: ToolCall,
|
||||||
): x is { name: Name; args: z.infer<Args> } => x.name === name;
|
): x is { name: Name; args: z.infer<Args>; id?: string } => x.name === name;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,8 +195,7 @@ export async function callTools(
|
|||||||
{
|
{
|
||||||
name: "buy-stock",
|
name: "buy-stock",
|
||||||
content: {
|
content: {
|
||||||
toolCallId:
|
toolCallId: buyStockToolCall.id ?? "",
|
||||||
message.tool_calls?.find((tc) => tc.name === "buy-stock")?.id ?? "",
|
|
||||||
snapshot,
|
snapshot,
|
||||||
quantity: buyStockToolCall.args.quantity,
|
quantity: buyStockToolCall.args.quantity,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { ChatOpenAI } from "@langchain/openai";
|
import { ChatOpenAI } from "@langchain/openai";
|
||||||
import { TripDetails, TripPlannerState, TripPlannerUpdate } from "../types";
|
import { TripDetails, TripPlannerState, TripPlannerUpdate } from "../types";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { formatMessages } from "agent/utils/format-messages";
|
import { formatMessages } from "agent/utils/format-messages";
|
||||||
|
import { ToolMessage } from "@langchain/langgraph-sdk";
|
||||||
|
import { DO_NOT_RENDER_ID_PREFIX } from "@/lib/ensure-tool-responses";
|
||||||
|
|
||||||
function calculateDates(
|
function calculateDates(
|
||||||
startDate: string | undefined,
|
startDate: string | undefined,
|
||||||
@@ -60,9 +63,9 @@ export async function extraction(
|
|||||||
.describe("The end date of the trip. Should be in YYYY-MM-DD format"),
|
.describe("The end date of the trip. Should be in YYYY-MM-DD format"),
|
||||||
numberOfGuests: z
|
numberOfGuests: z
|
||||||
.number()
|
.number()
|
||||||
.optional()
|
.describe(
|
||||||
.default(2)
|
"The number of guests for the trip. Should default to 2 if not specified",
|
||||||
.describe("The number of guests for the trip"),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools([
|
const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools([
|
||||||
@@ -96,15 +99,13 @@ Extract only what is specified by the user. It is okay to leave fields blank if
|
|||||||
{ role: "human", content: humanMessage },
|
{ role: "human", content: humanMessage },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const extractedDetails = response.tool_calls?.[0]?.args as
|
const toolCall = response.tool_calls?.[0];
|
||||||
| z.infer<typeof schema>
|
if (!toolCall) {
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (!extractedDetails) {
|
|
||||||
return {
|
return {
|
||||||
messages: [response],
|
messages: [response],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const extractedDetails = toolCall.args as z.infer<typeof schema>;
|
||||||
|
|
||||||
const { startDate, endDate } = calculateDates(
|
const { startDate, endDate } = calculateDates(
|
||||||
extractedDetails.startDate,
|
extractedDetails.startDate,
|
||||||
@@ -114,13 +115,19 @@ Extract only what is specified by the user. It is okay to leave fields blank if
|
|||||||
const extractionDetailsWithDefaults: TripDetails = {
|
const extractionDetailsWithDefaults: TripDetails = {
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
numberOfGuests: extractedDetails.numberOfGuests
|
numberOfGuests: extractedDetails.numberOfGuests ?? 2,
|
||||||
? extractedDetails.numberOfGuests
|
|
||||||
: 2,
|
|
||||||
location: extractedDetails.location,
|
location: extractedDetails.location,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extractToolResponse: ToolMessage = {
|
||||||
|
type: "tool",
|
||||||
|
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
|
||||||
|
tool_call_id: toolCall.id ?? "",
|
||||||
|
content: "Successfully extracted trip details",
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tripDetails: extractionDetailsWithDefaults,
|
tripDetails: extractionDetailsWithDefaults,
|
||||||
|
messages: [response, extractToolResponse],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,46 +5,27 @@ import type ComponentMap from "../../uis/index";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { LangGraphRunnableConfig } from "@langchain/langgraph";
|
import { LangGraphRunnableConfig } from "@langchain/langgraph";
|
||||||
import { getAccommodationsListProps } from "../utils/get-accommodations";
|
import { getAccommodationsListProps } from "../utils/get-accommodations";
|
||||||
|
import { findToolCall } from "../../find-tool-call";
|
||||||
|
|
||||||
const schema = z.object({
|
const listAccommodationsSchema = z
|
||||||
listAccommodations: z
|
.object({})
|
||||||
.boolean()
|
.describe("A tool to list accommodations for the user");
|
||||||
.optional()
|
const listRestaurantsSchema = z
|
||||||
.describe(
|
.object({})
|
||||||
"Whether or not the user has requested a list of accommodations for their trip.",
|
.describe("A tool to list restaurants for the user");
|
||||||
),
|
|
||||||
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
|
const ACCOMMODATIONS_TOOLS = [
|
||||||
.boolean()
|
{
|
||||||
.optional()
|
name: "list-accommodations",
|
||||||
.describe(
|
description: "A tool to list accommodations for the user",
|
||||||
"Whether or not the user has requested a list of restaurants for their trip.",
|
schema: listAccommodationsSchema,
|
||||||
),
|
},
|
||||||
bookRestaurant: z
|
{
|
||||||
.boolean()
|
name: "list-restaurants",
|
||||||
.optional()
|
description: "A tool to list restaurants for the user",
|
||||||
.describe(
|
schema: listRestaurantsSchema,
|
||||||
"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(
|
export async function callTools(
|
||||||
state: TripPlannerState,
|
state: TripPlannerState,
|
||||||
@@ -57,16 +38,7 @@ export async function callTools(
|
|||||||
const ui = typedUi<typeof ComponentMap>(config);
|
const ui = typedUi<typeof ComponentMap>(config);
|
||||||
|
|
||||||
const llm = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools(
|
const llm = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools(
|
||||||
[
|
ACCOMMODATIONS_TOOLS,
|
||||||
{
|
|
||||||
name: "trip-planner",
|
|
||||||
description: "A series of actions to take for planning a trip",
|
|
||||||
schema,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
tool_choice: "trip-planner",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await llm.invoke([
|
const response = await llm.invoke([
|
||||||
@@ -78,40 +50,31 @@ export async function callTools(
|
|||||||
...state.messages,
|
...state.messages,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tripPlan = response.tool_calls?.[0]?.args as
|
const listAccommodationsToolCall = response.tool_calls?.find(
|
||||||
| z.infer<typeof schema>
|
findToolCall("list-accommodations")<typeof listAccommodationsSchema>,
|
||||||
| undefined;
|
);
|
||||||
const toolCallId = response.tool_calls?.[0]?.id;
|
const listRestaurantsToolCall = response.tool_calls?.find(
|
||||||
if (!tripPlan || !toolCallId) {
|
findToolCall("list-restaurants")<typeof listRestaurantsSchema>,
|
||||||
throw new Error("No trip plan found");
|
);
|
||||||
|
|
||||||
|
if (!listAccommodationsToolCall && !listRestaurantsToolCall) {
|
||||||
|
throw new Error("No tool calls found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tripPlan.listAccommodations) {
|
if (listAccommodationsToolCall) {
|
||||||
ui.push(
|
ui.push(
|
||||||
{
|
{
|
||||||
name: "accommodations-list",
|
name: "accommodations-list",
|
||||||
content: {
|
content: {
|
||||||
toolCallId,
|
toolCallId: listAccommodationsToolCall.id ?? "",
|
||||||
...getAccommodationsListProps(state.tripDetails),
|
...getAccommodationsListProps(state.tripDetails),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ message: response },
|
{ message: response },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (tripPlan.bookAccommodation && tripPlan.accommodationName) {
|
|
||||||
ui.push(
|
|
||||||
{
|
|
||||||
name: "book-accommodation",
|
|
||||||
content: {
|
|
||||||
tripDetails: state.tripDetails,
|
|
||||||
accommodationName: tripPlan.accommodationName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ message: response },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tripPlan.listRestaurants) {
|
if (listRestaurantsToolCall) {
|
||||||
ui.push(
|
ui.push(
|
||||||
{
|
{
|
||||||
name: "restaurants-list",
|
name: "restaurants-list",
|
||||||
@@ -121,19 +84,6 @@ export async function callTools(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tripPlan.bookRestaurant && tripPlan.restaurantName) {
|
|
||||||
ui.push(
|
|
||||||
{
|
|
||||||
name: "book-restaurant",
|
|
||||||
content: {
|
|
||||||
tripDetails: state.tripDetails,
|
|
||||||
restaurantName: tripPlan.restaurantName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ message: response },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: [response],
|
messages: [response],
|
||||||
ui: ui.items,
|
ui: ui.items,
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import StockPrice from "./stockbroker/stock-price";
|
import StockPrice from "./stockbroker/stock-price";
|
||||||
import PortfolioView from "./stockbroker/portfolio-view";
|
import PortfolioView from "./stockbroker/portfolio-view";
|
||||||
import AccommodationsList from "./trip-planner/accommodations-list";
|
import AccommodationsList from "./trip-planner/accommodations-list";
|
||||||
import BookAccommodation from "./trip-planner/book-accommodation";
|
|
||||||
import RestaurantsList from "./trip-planner/restaurants-list";
|
import RestaurantsList from "./trip-planner/restaurants-list";
|
||||||
import BookRestaurant from "./trip-planner/book-restaurant";
|
|
||||||
import BuyStock from "./stockbroker/buy-stock";
|
import BuyStock from "./stockbroker/buy-stock";
|
||||||
import Plan from "./open-code/plan";
|
import Plan from "./open-code/plan";
|
||||||
import ProposedChange from "./open-code/proposed-change";
|
import ProposedChange from "./open-code/proposed-change";
|
||||||
@@ -12,9 +10,7 @@ const ComponentMap = {
|
|||||||
"stock-price": StockPrice,
|
"stock-price": StockPrice,
|
||||||
portfolio: PortfolioView,
|
portfolio: PortfolioView,
|
||||||
"accommodations-list": AccommodationsList,
|
"accommodations-list": AccommodationsList,
|
||||||
"book-accommodation": BookAccommodation,
|
|
||||||
"restaurants-list": RestaurantsList,
|
"restaurants-list": RestaurantsList,
|
||||||
"book-restaurant": BookRestaurant,
|
|
||||||
"buy-stock": BuyStock,
|
"buy-stock": BuyStock,
|
||||||
"code-plan": Plan,
|
"code-plan": Plan,
|
||||||
"proposed-change": ProposedChange,
|
"proposed-change": ProposedChange,
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export default function AccommodationsList({
|
|||||||
type: "tool",
|
type: "tool",
|
||||||
tool_call_id: toolCallId,
|
tool_call_id: toolCallId,
|
||||||
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
|
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
|
||||||
name: "trip-planner",
|
name: "book-accommodation",
|
||||||
content: JSON.stringify(orderDetails),
|
content: JSON.stringify(orderDetails),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
6411
pnpm-lock.yaml
generated
6411
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user