Merge pull request #249 from mtwn105/main

Added new Project: AI Travel Agent Team
This commit is contained in:
Shubham Saboo 2025-06-20 18:27:24 -05:00 committed by GitHub
commit e47c443949
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 17030 additions and 0 deletions

View file

@ -95,6 +95,7 @@ A curated collection of **Awesome LLM apps built with RAG, AI Agents, Multi-agen
* [👨‍🏫 AI Teaching Agent Team](advanced_ai_agents/multi_agent_apps/agent_teams/ai_teaching_agent_team/)
* [💻 Multimodal Coding Agent Team](advanced_ai_agents/multi_agent_apps/agent_teams/multimodal_coding_agent_team/)
* [✨ Multimodal Design Agent Team](advanced_ai_agents/multi_agent_apps/agent_teams/multimodal_design_agent_team/)
* [🌏 AI Travel Planner Agent Team](/advanced_ai_agents/multi_agent_apps/agent_teams/ai_travel_planner_team/)
### 🗣️ Voice AI Agents

View file

@ -0,0 +1,70 @@
# ✈️ TripCraft AI
**Your journey, perfectly crafted with intelligence.**
Travel planning is overwhelming—juggling dozens of tabs, comparing conflicting info, spending hours just to get started. TripCraft AI makes that disappear. It's a multi-agent AI system that turns simple inputs into complete travel itineraries. Describe your ideal trip, and it handles flights, hotels, activities, and budget automatically.
## 🎯 Goal
Make travel planning effortless and personal. No stress, no endless research—just a plan that feels crafted specifically for you.
---
## ⚙️ How It Works
1. **🎯 Input Your Vision** - Fill out a form with destination, dates, budget, travel style, and preferences
2. **🤖 AI Agents Collaborate** - Specialized agents handle flights, hotels, activities, and budgeting in parallel
3. **🗺️ Get Your Itinerary** - Receive a complete day-by-day plan with bookings, costs, and recommendations
### Key Features
- **Personalized Planning** - Tailored to your travel style and interests
- **Hidden Gems Discovery** - Beyond typical tourist spots using advanced search
- **Smart Optimization** - Balances cost, time, and experience
- **Complete Packages** - Everything from flights to dining recommendations
---
## 🛠️ Tech Stack
**Frontend:** Next.js, React, TypeScript
**Backend:** Python, FastAPI, PostgreSQL
**AI:** Agno (agent coordination), Gemini (LLM), Exa (search), Firecrawl (web scraping)
**APIs:** Google Flights, Kayak
---
## 📸 Visuals
![Image](https://github.com/user-attachments/assets/5fae2938-6d2c-4fc7-86be-d22bb84729a6)
![Image](https://github.com/user-attachments/assets/1bd6e98f-ae32-47be-90a0-23ee6f06c613)
![Image](https://github.com/user-attachments/assets/45db7d19-67ca-4c92-985f-79a7cb976b1c)
![Image](https://github.com/user-attachments/assets/7a06c3de-281d-4820-a517-ea81137289d7)
![Image](https://github.com/user-attachments/assets/523f0d02-8a72-4709-b3d4-5102f1d1b950)
![Image](https://github.com/user-attachments/assets/dbab944a-7678-4eae-9ead-05f15c3de407)
---
## 👥 About
**Built by**: Amit Wani [@mtwn105](https://github.com/mtwn105)
Full-stack developer and software engineer passionate about building intelligent systems that solve real-world problems. TripCraft AI represents the intersection of advanced AI capabilities and practical travel planning needs.
---
## 🎬 Demo Video Link
[https://youtu.be/eTll7EdQyY8](https://youtu.be/eTll7EdQyY8)
---
## 🤖 AI Agents
Six specialized agents work together to create comprehensive travel plans:
1. **🏛️ Destination Explorer** - Researches attractions, landmarks, and experiences
2. **🏨 Hotel Search Agent** - Finds accommodations based on location, budget, and amenities
3. **🍽️ Dining Agent** - Recommends restaurants and culinary experiences
4. **💰 Budget Agent** - Handles cost optimization and financial planning
5. **✈️ Flight Search Agent** - Plans air travel routes and comparisons
6. **📅 Itinerary Specialist** - Creates detailed day-by-day schedules with optimal timing

View file

@ -0,0 +1,33 @@
---
description:
globs:
alwaysApply: true
---
You are an expert in Python, FastAPI, and scalable API development.
Here are some best practices and rules you must follow:
* **Python Version**: Python 3.12
* **Frameworks and Tools**:
* `pydantic`
* `fastapi`
* `sqlalchemy`
* `uv` for dependency management
* `loguru` for logging
### Coding Standards
1. **Use Meaningful Names**: Choose descriptive variable, function, and class names.
2. **Follow PEP 8**: Adhere to the Python Enhancement Proposal 8 style guide for formatting.
3. **Use Docstrings**: Document functions and classes with docstrings to explain their purpose.
4. **Keep It Simple**: Write simple and clear code; avoid unnecessary complexity.
5. **Use List Comprehensions**: Prefer list comprehensions for creating lists over traditional loops when appropriate.
6. **Handle Exceptions**: Use try-except blocks to handle exceptions gracefully.
7. **Use Type Hints**: Utilize type hints for better code clarity and type checking.
8. **Avoid Global Variables**: Limit the use of global variables to reduce side effects.
9. **Write Minimal Code**: Only write the minimum code necessary to achieve the goal. Avoid overengineering.
10. **Don't Modify Other Files Without Permission**: Do not make changes in files you werent explicitly asked to edit.
> **Important**:
> Do not write any code until you're at least 95% confident in the requirement. Ask for clarification if needed.

View file

@ -0,0 +1,21 @@
# Bright Data credentials
BRIGHT_DATA_API_TOKEN=your_bright_data_api_token
BRIGHT_DATA_BROWSER_AUTH=your_bright_data_browser_auth
# Database connection URL
DATABASE_URL=your_database_url
# OpenRouter API key
OPENROUTER_API_KEY=your_openrouter_api_key
# OpenAI API key
OPENAI_API_KEY=your_openai_api_key
# Cloudflare R2 configuration
CLOUDFLARE_ACCOUNT_ID=your_cloudflare_account_id
CLOUDFLARE_R2_ACCESS_KEY_ID=your_r2_access_key_id
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_r2_secret_access_key
EXA_API_KEY=EXA_API_KEY
FIRECRAWL_API_KEY=FIRECRAWL_API_KEY

View file

@ -0,0 +1,47 @@
FROM python:3.12-slim-bookworm AS builder
# Install uv
RUN pip install --no-cache-dir uv
# Copy dependency files
WORKDIR /app
COPY pyproject.toml uv.lock ./
# Install dependencies into the system Python
RUN uv pip install --system -e .
# Final image - ultra slim
FROM python:3.12-slim-bookworm
# Install runtime dependencies, Node.js, PNPM, PostgreSQL client libraries, and FFmpeg
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl \
libpq-dev postgresql-client ffmpeg && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
npm install -g pnpm && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
npm cache clean --force && \
pip install --no-cache-dir psycopg2-binary gunicorn
# Copy Python packages from the builder stage
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
# Copy application code (only what's needed)
WORKDIR /app
COPY app/ app/
COPY agents/ agents/
COPY config/ config/
COPY models/ models/
COPY routers/ routers/
COPY services/ services/
COPY api.py server.py ./
# Create log directory with appropriate permissions
RUN mkdir -p logs && chmod 777 logs
EXPOSE 8001
# Use gunicorn with uvicorn workers
CMD ["gunicorn", "server:app", "--workers", "1", "--worker-class", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8001", "--timeout", "0", "--keep-alive", "5"]

View file

@ -0,0 +1,108 @@
# TripCraft AI - Agent Architecture
TripCraft AI uses a sophisticated multi-agent system powered by Agno to create personalized travel experiences. This document explains the different agents and their roles in the system.
## Team Structure
The system is orchestrated by the "TripCraft AI Team", which coordinates multiple specialized agents to create comprehensive travel plans. The team operates in a coordinated mode, ensuring all aspects of travel planning are handled efficiently.
### Core Team Members
1. **Destination Explorer**
- Primary role: Researches and recommends tourist attractions and experiences
- Tools: ExaTools for deep web research
- Focus areas:
- Famous landmarks and monuments
- Popular tourist spots
- Museums and cultural sites
- Shopping areas
- Family-friendly activities
- Provides structured information about attractions including opening hours, fees, and visit duration
2. **Hotel Search Agent**
- Primary role: Accommodation research and recommendations
- Focuses on finding the perfect stay based on:
- Location preferences
- Budget constraints
- Required amenities
- Room types
- Property features
3. **Dining Agent**
- Primary role: Restaurant and culinary experience recommendations
- Considers:
- Cuisine types
- Price ranges
- Dietary restrictions
- Ambiance and atmosphere
- Location and accessibility
- Special dining experiences
4. **Budget Agent**
- Primary role: Financial planning and cost optimization
- Responsibilities:
- Trip cost breakdown
- Budget allocation
- Cost-saving recommendations
- Currency considerations
- Emergency fund planning
5. **Flight Search Agent**
- Primary role: Air travel planning and optimization
- Handles:
- Flight route research
- Airline comparisons
- Schedule optimization
- Connection planning
- Airport transfer coordination
6. **Itinerary Specialist**
- Primary role: Creates detailed day-by-day travel schedules
- Expertise:
- Hour-by-hour activity planning
- Optimized timing for attractions
- Transportation scheduling
- Realistic travel times
- Buffer time management
- Weather-adaptive scheduling
- Traveler-specific pacing
## Team Coordination
The team works together through a sophisticated coordination system that:
1. Analyzes user preferences and requirements
2. Delegates tasks to specialized agents
3. Combines individual agent outputs into a cohesive travel plan
4. Ensures all aspects of the trip are properly synchronized
5. Maintains budget alignment across all decisions
## Tools and Technologies
The agents utilize various tools including:
- **ReasoningTools**: For logical decision-making and plan optimization
- **ExaTools**: For deep web research and information gathering
- **FirecrawlTools**: For real-time data and current information
## Output Format
The team produces detailed travel itineraries that include:
- Executive summary of the trip
- Comprehensive travel logistics
- Day-by-day itineraries
- Detailed accommodation information
- Curated experiences and activities
- Complete budget breakdown
## Best Practices
The agent system follows these key principles:
1. Thorough analysis of user preferences
2. Detailed research using multiple data sources
3. Practical and implementable recommendations
4. Backup options and contingency plans
5. Clear communication and structured output
6. Budget consciousness across all decisions
## Integration
This agent architecture is designed to work seamlessly with the TripCraft AI backend, providing a robust foundation for creating personalized travel experiences that feel both magical and practical.

View file

@ -0,0 +1,39 @@
from agno.agent import Agent
from config.llm import model
budget_agent = Agent(
name="Budget Optimizer",
role="Calculate costs and optimize travel budgets when asked by team leader",
model=model,
description="You research costs, compare prices, and optimize travel budgets when assigned by the team leader. When plans exceed budget, you suggest strategic adjustments to bring costs in line while preserving the core travel experience.",
instructions=[
"# Budget Optimization Instructions",
"",
"1. Analyze total budget and cost requirements:",
" - Review total budget limit",
" - Calculate costs for transportation, accommodations, activities, food",
" - Identify any components exceeding budget",
"",
"2. If over budget, suggest cost-saving alternatives:",
" - Alternative accommodations or locations",
" - Different transportation options",
" - Mix of premium and budget experiences",
" - Free or lower-cost activity substitutes",
" - Budget-friendly dining recommendations",
"",
"3. Research and recommend money-saving strategies:",
" - Early booking discounts",
" - Package deals",
" - Off-peak pricing",
" - Local passes and discount cards",
"",
"4. Present clear budget breakdown showing:",
" - Original vs optimized costs",
" - Specific savings per category",
" - Alternative options",
" - Hidden cost warnings",
"",
"Format all amounts in user's preferred currency with clear comparisons between original and optimized budgets.",
],
markdown=True,
)

View file

@ -0,0 +1,68 @@
from agno.agent import Agent
from agno.tools.exa import ExaTools
from agno.tools.firecrawl import FirecrawlTools
from config.llm import model
destination_agent = Agent(
name="Destination Explorer",
model=model,
tools=[
ExaTools(
num_results=10,
),
],
description="You are a destination research agent that focuses on recommending mainstream tourist attractions and classic experiences that most travelers would enjoy. You prioritize well-known landmarks and popular activities while keeping recommendations general and widely appealing.",
instructions=[
"1. Focus on mainstream attractions with thoughtful guidance:",
" - Famous landmarks and monuments",
" - Popular tourist spots",
" - Well-known museums",
" - Classic shopping areas",
" - Common tourist activities",
"",
"2. Guide visitors with simple reasoning:",
" - Suggest crowd-pleasing activities",
" - Focus on family-friendly locations",
" - Recommend proven tourist routes",
" - Include popular photo spots",
"",
"3. Present clear attraction information:",
" - Simple description",
" - General location",
" - Regular opening hours",
" - Standard entrance fees",
" - Typical visit duration",
" - Basic visitor tips",
"",
"4. Organize information logically:",
" - Main attractions first",
" - Common day trips",
" - Standard tourist areas",
" - Popular activities",
"",
"Use tools to find and verify tourist information.",
"Keep suggestions general and widely appealing.",
],
expected_output="""
# Tourist Guide
## Main Attractions
List of most popular tourist spots
## Common Activities
Standard tourist activities and experiences
## Popular Areas
Well-known districts and neighborhoods
## Basic Information
- General visiting tips
- Common transportation options
- Standard tourist advice
""",
markdown=True,
show_tool_calls=True,
add_datetime_to_instructions=True,
retries=3,
delay_between_retries=2,
exponential_backoff=True,
)

View file

@ -0,0 +1,68 @@
from agno.agent import Agent
from agno.tools.firecrawl import FirecrawlTools
from tools.google_flight import get_google_flights
from config.llm import model
flight_search_agent = Agent(
name="Flight Search Assistant",
model=model,
tools=[
# FirecrawlTools(poll_interval=10),
# kayak_flight_url_generator,
get_google_flights,
],
instructions=[
"You are a sophisticated flight search and analysis assistant for comprehensive travel planning. For any user query:",
"1. Parse complete flight requirements including:",
" - Origin and destination cities",
" - Travel dates (outbound and return)",
" - Number of travelers (adults, children, infants)",
" - Preferred cabin class",
" - Any specific airlines or routing preferences",
" - Budget constraints if specified",
# "2. Search and analyze multiple flight options:",
"2. Search for flight options:",
# " - Use kayak_url_generator to create appropriate search URLs",
# " - Navigate to and extract data from flight search results",
" - Use get_google_flights to get flight results",
" - Consider both direct and connecting flights",
" - Compare different departure times and airlines",
"3. For each viable flight option, extract:",
" - Complete pricing breakdown (base fare, taxes, total)",
" - Flight numbers and operating airlines",
" - Detailed timing (departure, arrival, duration, layovers)",
" - Aircraft types and amenities when available",
" - Baggage allowance and policies",
"4. Organize and present options with focus on:",
" - Best value for money",
" - Convenient timing and minimal layovers",
" - Reliable airlines with good service records",
" - Flexibility and booking conditions",
"5. Provide practical recommendations considering:",
" - Price trends and booking timing",
" - Alternative dates or nearby airports if beneficial",
" - Loyalty program benefits if applicable",
" - Special requirements (extra legroom, dietary, etc.)",
"6. Include booking guidance:",
" - Direct booking links when available",
" - Fare rules and change policies",
" - Required documents and visa implications",
# "7. Always close browser sessions after completion",
],
expected_output="""
All flight details with the following fields:
- flight_number (str): The flight number of the flight
- price (str): The price of the flight
- airline (str): The airline of the flight
- departure_time (str): The departure time of the flight
- arrival_time (str): The arrival time of the flight
- duration (str): The duration of the flight
- stops (int): The number of stops of the flight
""",
markdown=True,
show_tool_calls=True,
debug_mode=True,
retries=3,
delay_between_retries=2,
exponential_backoff=True,
)

View file

@ -0,0 +1,116 @@
from agno.tools.exa import ExaTools
from config.llm import model
from agno.agent import Agent
dining_agent = Agent(
name="Culinary Guide",
role="Research dining and food experiences when asked by team leader",
model=model,
tools=[ExaTools()],
description="You research restaurants, food markets, culinary experiences, and dining options when assigned by the team leader.",
instructions=[
"# Culinary Research and Recommendation Assistant",
"",
"## Task 1: Query Processing",
"- Parse dining preferences from user query",
"- Extract:",
" - Location/area",
" - Cuisine preferences",
" - Dietary restrictions",
" - Budget range",
" - Meal timing",
" - Group size",
" - Special requirements (e.g., family-friendly, romantic)",
"",
"## Task 2: Research & Data Collection",
"- Search for restaurants and food experiences using ExaTools",
"- Gather information about:",
" - Local cuisine specialties",
" - Popular food markets",
" - Culinary experiences",
" - Operating hours",
" - Price ranges",
" - Reservation policies",
"",
"## Task 3: Content Analysis",
"- Analyze restaurant reviews and ratings",
"- Evaluate:",
" - Food quality",
" - Service standards",
" - Ambiance",
" - Value for money",
" - Dietary accommodation",
" - Family-friendliness",
"",
"## Task 4: Data Processing",
"- Filter results based on:",
" - Dietary requirements",
" - Budget constraints",
" - Location preferences",
" - Special requirements",
"- Validate information completeness",
"",
"## Task 5: Results Presentation",
"Present recommendations in a clear, organized format:",
"",
"### Restaurant Recommendations",
"For each restaurant, include:",
"- Name and cuisine type",
"- Price range (e.g., $, $$, $$$)",
"- Rating and brief review summary",
"- Location and accessibility",
"- Operating hours",
"- Dietary options available",
"- Special features (e.g., outdoor seating, view)",
"- Reservation requirements",
"- Popular dishes to try",
"",
"### Food Markets & Culinary Experiences",
"- Market names and specialties",
"- Best times to visit",
"- Must-try local foods",
"- Cultural significance",
"",
"### Additional Information",
"- Local food customs and etiquette",
"- Peak dining hours to avoid",
"- Transportation options",
"- Food safety tips",
"",
"Format the output in clear sections with emojis and bullet points for better readability.",
],
expected_output="""
Present dining recommendations in a clear, organized format with the following sections:
# 🍽️ Restaurant Recommendations
For each recommended restaurant:
- Name and cuisine type
- Price range and value rating
- Location and accessibility
- Operating hours
- Dietary options
- Special features
- Popular dishes
- Reservation info
# 🛍️ Food Markets & Experiences
- Market names and specialties
- Best visiting times
- Local food highlights
- Cultural significance
# Additional Information
- Local customs
- Peak hours
- Transportation
- Safety tips
Use emojis and clear formatting for better readability.
""",
markdown=True,
show_tool_calls=True,
debug_mode=True,
retries=3,
delay_between_retries=2,
exponential_backoff=True,
)

View file

@ -0,0 +1,82 @@
from agno.agent import Agent
from tools.kayak_hotel import kayak_hotel_url_generator
from tools.scrape import scrape_website
from config.llm import model
from models.hotel import HotelResult, HotelResults
hotel_search_agent = Agent(
name="Hotel Search Assistant",
model=model,
tools=[
scrape_website,
kayak_hotel_url_generator,
],
instructions=[
"# Hotel Search and Data Extraction Assistant",
"",
"## Task 1: Query Processing",
"- Parse hotel search parameters from user query",
"- Extract:",
" - Destination",
" - Check-in/out dates",
" - Number of guests (adults, children)",
" - Room requirements",
" - Budget constraints",
" - Preferred amenities",
" - Location preferences",
"",
"## Task 2: URL Generation & Initial Scraping",
"- Generate Kayak URL using `kayak_hotel_url_generator`",
"- Perform initial content scrape with `scrape_website`",
"- Handle URL encoding for special characters in destination names",
"",
"## Task 3: Data Extraction",
"- Parse hotel listings from scraped content",
"- Extract key details:",
" - Prices (including taxes and fees)",
" - Amenities (especially family-friendly features)",
" - Ratings and reviews",
" - Location details",
" - Room types and availability",
" - Cancellation policies",
"- Handle dynamic loading of results",
"- Navigate multiple pages if needed",
"",
"## Task 4: Data Processing",
"- Structure extracted hotel data according to HotelResult model",
"- Validate data completeness",
"- Filter results based on:",
" - Budget constraints",
" - Required amenities",
" - Location preferences",
" - Family-friendly features",
"",
"## Task 5: Results Presentation",
"- Format results clearly with:",
" - Hotel name and rating",
" - Price breakdown",
" - Location and accessibility",
" - Key amenities",
" - Family-friendly features",
" - Booking policies",
"- Sort results by relevance to user preferences",
"- Include direct booking links",
"",
],
expected_output="""
List of hotels with the following fields for each hotel:
- hotel_name (str): The name of the hotel
- price (str): The price of the hotel
- rating (str): The rating of the hotel
- address (str): The address of the hotel
- amenities (List[str]): The amenities of the hotel
- description (str): The description of the hotel
- url (str): The url of the hotel
""",
markdown=True,
show_tool_calls=True,
debug_mode=True,
retries=3,
delay_between_retries=2,
exponential_backoff=True,
)

View file

@ -0,0 +1,127 @@
from agno.agent import Agent
from agno.tools.exa import ExaTools
from agno.tools.firecrawl import FirecrawlTools
from agno.tools.reasoning import ReasoningTools
from config.llm import model
from typing import Optional
from datetime import datetime, timedelta
from textwrap import dedent
itinerary_agent = Agent(
name="Itinerary Specialist",
model=model,
tools=[
ExaTools(num_results=8),
FirecrawlTools(formats=["markdown"]),
ReasoningTools(add_instructions=True),
],
markdown=True,
description=dedent(
"""\
You are a master itinerary creator with expertise in crafting detailed, perfectly-timed daily travel plans.
You turn abstract travel details into structured, hour-by-hour plans that maximize enjoyment while maintaining
a realistic pace. You're skilled at adapting schedules to match traveler preferences, weather conditions,
opening hours, and local customs. Your itineraries are practical, thoroughly researched, and full of
insider timing tips that make travel smooth and stress-free."""
),
instructions=[
"1. Create perfectly balanced day-by-day itineraries with meticulous timing:",
" - Structure each day into morning, afternoon, and evening blocks",
" - Include exact timing for each activity (start/end times)",
" - Account for realistic travel times between locations",
" - Balance sightseeing with leisure and rest periods",
" - Adapt pace to match traveler preferences (relaxed, moderate, fast)",
"",
"2. Ensure practical logistics in all schedules:",
" - Verify operating hours for all attractions, restaurants, and services",
" - Account for common delays (security lines, crowds, traffic)",
" - Include buffer time between activities",
" - Check for day-specific closures (weekends, holidays, seasonal)",
" - Consider local transportation options and schedules",
"",
"3. Optimize activity timing with expert knowledge:",
" - Schedule visits during off-peak hours when possible",
" - Plan indoor activities during likely rainy/hot periods",
" - Arrange sunrise/sunset experiences at optimal times",
" - Schedule meals during traditional local dining hours",
" - Time activities to avoid rush hour transportation",
"",
"4. Create custom scheduling for specific traveler types:",
" - Families: Include kid-friendly breaks and early dinners",
" - Seniors: More relaxed pace with ample rest periods",
" - Young adults: Later start times and evening activities",
" - Luxury travelers: Timing for exclusive experiences",
" - Business travelers: Efficient scheduling around work commitments",
"",
"5. Enhance itineraries with practical timing details:",
" - Best arrival times to avoid lines at attractions",
" - Photography timing for optimal lighting",
" - Meal reservations timed around activities",
" - Shopping hours for local markets and stores",
" - Weather-dependent backup plans",
"",
"6. Research tools usage for accurate scheduling:",
" - Use Exa to research location-specific timing information",
" - Employ FirecrawlTools for current operating hours and conditions",
" - Use ReasoningTools to optimize activity sequence and timing",
"",
"7. Format day plans with maximum clarity:",
" - Use clear time blocks (8:00 AM - 9:30 AM)",
" - Include travel method and duration between locations",
" - Highlight reservation times and booking requirements",
" - Note required advance arrival times (security, check-in)",
" - Use emojis for better visual organization",
],
expected_output=dedent(
"""\
# Detailed Itinerary: {Destination} ({Start Date} - {End Date})
## Trip Overview
- **Dates**: {exact dates with day count}
- **Travelers**: {number and type}
- **Pace**: {relaxed/moderate/fast}
- **Style**: {luxury/mid-range/budget}
- **Priorities**: {key interests and goals}
## Day 1: {Day of Week}, {Date}
### Morning
- **7:00 AM - 8:00 AM**: Breakfast at {location}
- **8:30 AM - 10:30 AM**: {Activity} at {location}
* Notes: {special instructions, timing tips}
* Travel: {transport method, duration}
- **11:00 AM - 12:30 PM**: {Activity} at {location}
* Notes: {special instructions, timing tips}
* Travel: {transport method, duration}
### Afternoon
- **1:00 PM - 2:00 PM**: Lunch at {location}
- **2:30 PM - 4:30 PM**: {Activity} at {location}
* Notes: {special instructions, timing tips}
* Travel: {transport method, duration}
- **5:00 PM - 6:00 PM**: Rest/refresh at hotel
### Evening
- **7:00 PM - 8:30 PM**: Dinner at {location}
- **9:00 PM - 10:30 PM**: {Activity} at {location}
* Notes: {special instructions, timing tips}
* Travel: {transport method, duration}
## Day 2: {Day of Week}, {Date}
[Similar detailed breakdown]
[Continue for each day of the trip]
## Practical Notes
- **Weather Considerations**: {weather-related timing adjustments}
- **Transportation Tips**: {local transport timing advice}
- **Reservation Reminders**: {all pre-booked times}
- **Backup Plans**: {alternative schedules for weather/closures}
"""
),
add_datetime_to_instructions=True,
show_tool_calls=True,
retries=2,
delay_between_retries=2,
exponential_backoff=True,
)

View file

@ -0,0 +1,127 @@
from typing import TypeVar, Type, Any
from pydantic import BaseModel
from agno.agent import Agent
from loguru import logger
from config.llm import model
import json
import re
from pydantic import ValidationError
T = TypeVar("T", bound=BaseModel)
def clean_json_string(json_str: str) -> str:
"""
Clean a JSON string by removing markdown code blocks and any extra whitespace.
Args:
json_str (str): The JSON string to clean
Returns:
str: The cleaned JSON string
"""
# Remove markdown code blocks
json_str = re.sub(r"```(?:json)?\n?(.*?)```", r"\1", json_str, flags=re.DOTALL)
# If no code blocks found, use the original string
if not json_str.strip():
json_str = json_str
# Remove any leading/trailing whitespace
json_str = json_str.strip()
return json_str
async def convert_to_model(input_text: str, target_model: Type[T]) -> str:
"""
Convert input text into a specified Pydantic model using an Agno agent.
Args:
input_text (str): The input text to convert
target_model (Type[T]): The target Pydantic model class
Returns:
str: A JSON string that matches the model schema
"""
logger.info(
f"Converting input text to model: {target_model.__name__} : {input_text}"
)
structured_output_agent = Agent(
model=model,
description=(
"You are an expert at extracting structured travel planning information from unstructured, free-form user inputs. "
"Given a detailed user message, travel description, or conversation, your goal is to accurately populate a predefined trip schema. "
),
instructions=[
"Your task is to convert the input text into a valid JSON that matches the model schema exactly.",
"You must return ONLY the JSON object that matches the schema exactly - no other output.",
"When formatting text fields, you must:",
"- Use minimal, consistent formatting throughout",
"- Apply appropriate list formatting",
"- Format dates, times and structured data consistently",
"- Structure text concisely and clearly",
],
markdown=True,
expected_output="""
A valid JSON object that matches the provided schema.
Text fields should be clean and consistently formatted.
Do not include any explanations or additional text - return only the JSON object.
Without ```json or ```
""",
)
schema = target_model.model_json_schema()
schema_str = json.dumps(schema, indent=2)
# Create the prompt with model schema and clear instructions
prompt = f"""
Your task is to convert the input text into a valid JSON object that exactly matches the provided schema.
Do not include any explanations or additional text - return only the JSON object.
Model schema:
{schema_str}
Rules:
- Output must be valid JSON
- All required fields must be included
- Field types must match schema exactly
- No extra fields allowed
- Validate all constraints (min/max values, regex patterns, etc)
Text Formatting Requirements:
- Use consistent, clean text formatting throughout all string fields
- For list items, use bullet points () instead of asterisks (*)
- Minimize indentation and whitespace in text fields
- Use line breaks sparingly and consistently
- Avoid formatting characters like asterisks (*) in text
- Don't include unnecessary prefixes or labels in text content
- Format times, dates, durations, and prices consistently
- Make sure all fields contain data appropriate for their purpose
Input text to convert:
{input_text}
"""
# Get structured response from the agent
try:
response = await structured_output_agent.arun(prompt)
json_string = clean_json_string(response.content)
logger.info(f"Structured output agent response: {json_string}")
# Parse the JSON string
try:
json.loads(json_string)
return json_string
except json.JSONDecodeError as json_err:
logger.error(f"JSON parsing error: {str(json_err)}")
raise ValueError(f"Invalid JSON response: {str(json_err)}")
except Exception as e:
logger.error(f"Failed to parse response into {target_model.__name__}: {str(e)}")
raise ValueError(
f"Failed to parse response into {target_model.__name__}: {str(e)}"
)

View file

@ -0,0 +1,334 @@
from agno.team.team import Team
from config.llm import model, model2
from agents.destination import destination_agent
from agents.hotel import hotel_search_agent
from agents.food import dining_agent
from agents.budget import budget_agent
from agents.flight import flight_search_agent
from agents.itinerary import itinerary_agent
from loguru import logger
from agno.tools.reasoning import ReasoningTools
# def update_team_current_state(team: Team, state: str) -> str:
# """
# This function is used to set the current state of the team.
# """
# logger.info(f"The current state of the team is {state}")
# team.session_state["current_state"] = state
# return state
trip_planning_team = Team(
name="TripCraft AI Team",
mode="coordinate",
model=model,
tools=[ReasoningTools(add_instructions=True)],
members=[
destination_agent,
hotel_search_agent,
dining_agent,
budget_agent,
flight_search_agent,
itinerary_agent,
],
show_tool_calls=True,
markdown=True,
description=(
"You are the lead orchestrator of the TripCraft AI planning team. "
"Your mission is to transform the user's travel preferences into a magical, stress-free itinerary. "
"Based on a single input form, you'll collaborate with expert agents handling flights, stays, dining, activities, and budgeting. "
"The result should be a beautifully crafted, practical, and emotionally resonant travel plan that feels personally designed. "
"Every detail matters - from the exact timing of activities to the ambiance of recommended restaurants. "
"Your goal is to create an itinerary so thorough and thoughtful that it feels like having a personal travel concierge."
),
instructions=[
"1. Meticulously analyze the complete travel preferences from the user input:",
" - Primary destination and any secondary locations",
" - Exact travel dates including arrival and departure times",
" - Preferred pace (relaxed, moderate, or fast-paced) with specific timing preferences",
" - Travel style (luxury, mid-range, budget) with detailed expectations",
" - Budget range with currency and flexibility notes",
" - Companion details (solo, couple, family, friends) with group dynamics",
" - Accommodation requirements (room types, amenities, location preferences)",
" - Desired vibes (romantic, adventurous, relaxing, etc.) with specific examples",
" - Top priorities (Instagram spots, local experiences, food, shopping) ranked by importance",
" - Special interests, dietary restrictions, accessibility needs",
" - Previous travel experiences and preferences",
"",
"2. Transportation Planning:",
" - Map out exact routes from start location to all destinations",
" - Research optimal flight/train combinations considering:",
" • Departure/arrival times aligned with check-in/out times",
" • Layover durations and airport transfer times",
" • Airline alliance benefits and baggage policies",
" • Alternative airports and routes for cost optimization",
" - Plan local transportation between all points of interest",
"",
"3. Coordinate with Specialized Agents:",
" - Flight Agent: Detailed air travel options with timing and pricing",
" - Hotel Agent: Accommodation matches for each night with amenity details",
" - Dining Agent: Restaurant recommendations with cuisine, price, and ambiance",
" - Activity Agent: Curated experiences matching interests and pace",
" - Budget Agent: Cost optimization while maintaining experience quality",
"",
"4. Create Detailed Daily Schedules:",
" Morning (6am-12pm):",
" - Breakfast venues with opening hours and signature dishes",
" - Morning activities with exact durations and travel times",
" - Alternative options for weather contingencies",
"",
" Afternoon (12pm-6pm):",
" - Lunch recommendations with peak times and reservation needs",
" - Main sightseeing with entrance fees and skip-the-line options",
" - Rest periods aligned with pace preference",
"",
" Evening (6pm-midnight):",
" - Dinner venues with ambiance descriptions and dress codes",
" - Evening entertainment options",
" - Nightlife suggestions if requested",
"",
"5. Experience Enhancement:",
" - Research and highlight hidden gems matching user interests",
" - Identify unique local experiences with cultural significance",
" - Find Instagram-worthy locations with best photo times",
" - Source exclusive or unusual accommodation options",
" - Map romantic spots for couples or family-friendly venues",
"",
"6. Budget Management:",
" - Break down costs to the smallest detail:",
" • Transportation (flights, trains, taxis, public transit)",
" • Accommodations (nightly rates, taxes, fees)",
" • Activities (tickets, guides, equipment rentals)",
" • Meals (by venue type and meal time)",
" • Shopping allowance",
" • Emergency buffer",
" - Provide cost-saving alternatives while maintaining experience quality",
" - Consider seasonal pricing variations",
"",
"7. Research Tools Usage:",
" - Use Exa for deep destination research including:",
" • Seasonal events and festivals",
" • Local customs and etiquette",
" • Weather patterns and best visit times",
" - Employ Firecrawl for real-time data on:",
" • Venue reviews and ratings",
" • Current pricing and availability",
" • Booking platforms and deals",
"",
"8. Personalization Elements:",
" - Reference and incorporate past travel experiences",
" - Avoid previously visited locations unless requested",
" - Match recommendations to stated preferences",
" - Add personal touches based on special occasions or interests",
"",
"9. Final Itinerary Crafting:",
" - Ensure perfect flow between all elements",
" - Include buffer time for transitions",
" - Add local tips and insider knowledge",
" - Provide backup options for key elements",
" - Format for both inspiration and practical use",
],
expected_output="""
A meticulously detailed, day-by-day travel itinerary in Markdown format including:
**I. Executive Summary**
- 🎯 Trip Purpose & Vision
Primary goals and desired experiences
Special occasions or celebrations
Key preferences and must-haves
- Travel Overview
Exact dates with day count
All destinations in sequence
Group composition and dynamics
Overall style and pace
Total budget range and currency
- 💫 Experience Highlights
Signature moments and unique experiences
Special arrangements and exclusives
Instagram-worthy locations
Cultural immersion opportunities
**II. Travel Logistics**
- 🛫 Outbound Journey
Flight/train details with exact timings
Carrier information and booking references
Seat recommendations
Baggage allowances and restrictions
Airport/station transfer details
Check-in instructions
- 🛬 Return Journey
Return transportation specifics
Timing coordination with checkout
Alternative options if available
**III. Detailed Daily Itinerary**
For each day (e.g., "Day 1 - Monday, July 1, 2025"):
- 🌅 Morning (6am-12pm)
Wake-up time and morning routine
Breakfast venue with menu highlights
Morning activities with durations
Transport between locations
Tips for timing and crowds
- Afternoon (12pm-6pm)
Lunch recommendations with price range
Main activities and experiences
Rest periods and flexibility
Photo opportunities
Indoor/outdoor alternatives
- 🌙 Evening (6pm-onwards)
Dinner reservations and details
Evening entertainment
Nightlife options if desired
Transport back to accommodation
- 🏨 Accommodation
Property name and room type
Check-in/out times
Key amenities and features
Location benefits
Booking confirmation details
- 📝 Daily Notes
Weather considerations
Dress code requirements
Advance bookings needed
Local customs and tips
Emergency contacts
**IV. Accommodation Details**
For each property:
- 📍 Location & Access
Exact address and coordinates
Transport options and costs
Surrounding area highlights
Distance to key attractions
- 🛎 Property Features
Room types and views
Included amenities
Dining options
Special services
Unique selling points
- 💰 Costs & Booking
Nightly rates and taxes
Additional fees
Cancellation policy
Payment methods
Booking platform links
**V. Curated Experiences**
- 🎭 Activities & Attractions
Name and description
Operating hours and duration
Admission fees
Booking requirements
Insider tips
Alternative options
Accessibility notes
- 🍽 Dining Experiences
Restaurant details and cuisine
Price ranges and menu highlights
Ambiance and dress code
Reservation policies
Signature dishes
Dietary accommodation
View/seating recommendations
**VI. Comprehensive Budget**
- 💵 Total Trip Cost
Grand total in user's currency
Exchange rates used
Payment timeline
- 📊 Detailed Breakdown
Transportation
- Flights/trains
- Local transport
- Airport transfers
Accommodations
- Nightly rates
- Taxes and fees
- Extra services
Activities
- Admission fees
- Guide costs
- Equipment rental
Dining
- Breakfast allowance
- Lunch budget
- Dinner budget
- Drinks/snacks
Shopping & Souvenirs
Emergency Fund
Optional Upgrades
**VII. Essential Information**
- 📋 Pre-Trip Preparation
Visa requirements
Health and insurance
Packing recommendations
Weather forecasts
Currency exchange tips
- 🗺 Destination Guide
Local customs and etiquette
Language basics
Emergency contacts
Medical facilities
Shopping areas
Local transport options
- 📱 Digital Resources
Useful apps
Booking confirmations
Maps and directions
Restaurant reservations
Activity tickets
- Contingency Plans
Weather alternatives
Backup restaurants
Emergency contacts
Travel insurance details
Cancellation policies
Format the entire itinerary with:
Clear section headers
Consistent emoji usage
Bullet points and sub-bullets
Tables where appropriate
Highlighted important information
Links to all bookings and reservations
Day-specific weather forecasts
Local emergency numbers
Relevant photos and maps
""",
success_criteria=[
"✅ Complete itinerary with all travel days and activities",
"✅ Stays within budget constraints",
"✅ Matches user priorities and travel style",
"✅ Well-structured daily schedule matching user's pace",
"✅ Real flights and accommodations with costs and links",
"✅ Daily activities aligned with selected vibes",
"✅ Clear Markdown format with good visuals",
"✅ Realistic budget breakdown",
"✅ Personalized tips based on user profile",
"✅ Verified, real-world locations only",
],
enable_agentic_context=True,
share_member_interactions=True,
show_members_responses=True,
add_datetime_to_instructions=True,
add_member_tools_to_system_message=True,
# debug_mode=True,
telemetry=False,
)

View file

@ -0,0 +1,3 @@
from .app import app
__all__ = ["app"]

View file

@ -0,0 +1,55 @@
from fastapi import FastAPI, APIRouter
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from datetime import datetime, timezone
from contextlib import asynccontextmanager
from services.db_service import initialize_db_pool, close_db_pool
from router.plan import router as plan_router
router = APIRouter(prefix="/api")
@router.get("/health", summary="API Health Check")
async def health_check():
logger.debug("Health check requested")
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup logic
logger.info("API server started")
# Initialize database connection pool
logger.info("Initializing database connection pool")
await initialize_db_pool()
logger.info("Database connection pool initialized")
yield
# Shutdown logic
# Close database connection pool
logger.info("Closing database connection pool")
await close_db_pool()
logger.info("API server shutting down")
app = FastAPI(
title="TripCraft AI API",
description="API for running intelligent trip planning in the background",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
app.include_router(plan_router)

View file

@ -0,0 +1,55 @@
from config.logger import setup_logging
setup_logging(console_level="INFO")
from loguru import logger
logger.info("Starting the application")
logger.info("Loading environment variables")
from dotenv import load_dotenv
load_dotenv()
logger.info("Loaded environment variables")
logger.info("Loading agents")
from agents.flight import flight_search_agent
from agents.hotel import hotel_search_agent
logger.info("Loaded agents")
# structured_output_agent = Agent(
# name="Structured Output Generator",
# model=model2,
# instructions="Generate structured output in the specified schema format. Parse input data and format according to schema requirements. DO NOT include any other text in your response.",
# expected_output=dedent("""\
# A JSON object with the following fields:
# - status (str): Success or error status of the request (success or error)
# - message (str): Status message or error description
# - data: Object containing the flight results
# - flights: A list of flight results
# Each flight has the following fields:
# - flight_number (str): The flight number of the flight
# - price (str): The price of the flight
# - airline (str): The airline of the flight
# - departure_time (str): The departure time of the flight
# - arrival_time (str): The arrival time of the flight
# - duration (str): The duration of the flight
# - stops (int): The number of stops of the flight
# **DO NOT include any other text in your response.**
# }"""),
# markdown=True,
# show_tool_calls=True,
# debug_mode=True,
# response_model=FlightResults,
# )
# response = flight_search_agent.run("""
# Give me flights from Mumbai to Singapore for premium economy on 1 july 2025 for 2 adults and 1 child and sort by cheapest
# """)
# print(response.content)
response = hotel_search_agent.run("""
Give me hotels in Singapore for 2 adults and 1 child on 1 july 2025 to 10 july 2025 and sort by cheapest
""")
print(response.content)

View file

@ -0,0 +1,12 @@
from agno.models.google import Gemini
from agno.models.openai import OpenAIChat
from agno.models.openrouter import OpenRouter
# model = Gemini(id="gemini-2.0-flash-001", temperature=0.1)
# model2 = OpenAIChat(id="gpt-4o", temperature=0.1)
model = OpenRouter(id="google/gemini-2.0-flash-001", temperature=0.3, max_tokens=8096)
model2 = OpenRouter(id="openai/gpt-4o", temperature=0.1)
model_zero = OpenRouter(
id="google/gemini-2.0-flash-001", temperature=0.1, max_tokens=8096
)

View file

@ -0,0 +1,113 @@
import sys
import logging
import inspect
from typing import Dict, Any, Callable
from loguru import logger
from pathlib import Path
# Create logs directory if it doesn't exist
# LOGS_DIR = Path("logs")
# LOGS_DIR.mkdir(exist_ok=True)
def configure_logger(console_level: str = "INFO", log_format: str = None) -> None:
"""Configure loguru logger with console and file outputs
Args:
console_level: Minimum level for console logs
file_level: Minimum level for file logs
rotation: When to rotate log files (size or time)
retention: How long to keep log files
log_format: Optional custom format string
"""
# Remove default configuration
logger.remove()
# Use default format if none provided
if log_format is None:
log_format = "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
# Add console handler
logger.add(
sys.stderr,
format=log_format,
level=console_level,
colorize=True,
backtrace=True,
diagnose=True,
)
# # Add file handler
# logger.add(
# LOGS_DIR / "app.log",
# format=log_format,
# level=console_level,
# )
# Intercept standard library logging to loguru
class InterceptHandler(logging.Handler):
"""Intercepts standard library logging and redirects to loguru"""
def emit(self, record: logging.LogRecord) -> None:
# Get corresponding Loguru level if it exists
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
# Find caller from where originated the logged message
frame, depth = inspect.currentframe(), 0
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(
level, record.getMessage()
)
def patch_std_logging():
"""Patch all standard library loggers to use loguru"""
# Replace all existing handlers with the InterceptHandler
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
# Update all existing loggers
for name in logging.root.manager.loggerDict.keys():
logging_logger = logging.getLogger(name)
logging_logger.handlers = [InterceptHandler()]
logging_logger.propagate = False
# Update specific common libraries
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"):
logging_logger = logging.getLogger(logger_name)
logging_logger.handlers = [InterceptHandler()]
def setup_logging(console_level: str = "INFO", intercept_stdlib: bool = True) -> None:
"""Setup logging for the entire application
Args:
console_level: Minimum level for console output
file_level: Minimum level for file output
intercept_stdlib: Whether to patch standard library logging
"""
# Configure loguru
configure_logger(console_level=console_level)
# Optionally patch standard library logging
if intercept_stdlib:
patch_std_logging()
# Add extra context to logger
logger.configure(extra={"app_name": "decipher-research-agent"})
logger.info("Logging configured successfully")
def logger_hook(function_name: str, function_call: Callable, arguments: Dict[str, Any]):
"""Hook function that wraps the tool execution"""
logger.info(f"About to call {function_name} with arguments: {arguments}")
result = function_call(**arguments)
logger.info(f"Function call completed with result: {result}")
return result

View file

@ -0,0 +1,24 @@
#!/bin/bash
# Exit on any error
set -e
# Configuration
IMAGE_NAME="decipher-backend"
DOCKER_REGISTRY="mtwn105"
VERSION=$(git describe --tags --always)
# Build the Docker image
echo "Building Docker image..."
docker build -t $IMAGE_NAME:$VERSION .
# Tag the image with latest
docker tag $IMAGE_NAME:$VERSION $DOCKER_REGISTRY/$IMAGE_NAME:latest
docker tag $IMAGE_NAME:$VERSION $DOCKER_REGISTRY/$IMAGE_NAME:$VERSION
# Push the images
echo "Pushing Docker images..."
docker push $DOCKER_REGISTRY/$IMAGE_NAME:latest
docker push $DOCKER_REGISTRY/$IMAGE_NAME:$VERSION
echo "Successfully built and pushed version $VERSION"

View file

@ -0,0 +1,20 @@
from dotenv import load_dotenv
from loguru import logger
# Load environment variables
logger.info("Loading environment variables")
load_dotenv()
logger.info("Environment variables loaded")
# Import and setup logging configuration
from config.logger import setup_logging
# Configure logging with loguru
setup_logging(console_level="INFO")
from api.app import app
if __name__ == "__main__":
logger.info("Starting TripCraft AI API server")
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View file

@ -0,0 +1,35 @@
-- Create enum type for task status
CREATE TYPE plan_task_status AS ENUM ('queued', 'in_progress', 'success', 'error');
-- Create plan_tasks table
CREATE TABLE IF NOT EXISTS plan_tasks (
id SERIAL PRIMARY KEY,
trip_plan_id VARCHAR(50) NOT NULL,
task_type VARCHAR(50) NOT NULL,
status plan_task_status NOT NULL,
input_data JSONB NOT NULL,
output_data JSONB,
error_message VARCHAR(500),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Create index on trip_plan_id for faster lookups
CREATE INDEX IF NOT EXISTS idx_plan_tasks_trip_plan_id ON plan_tasks(trip_plan_id);
-- Create index on status for faster filtering
CREATE INDEX IF NOT EXISTS idx_plan_tasks_status ON plan_tasks(status);
-- Create trigger to automatically update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_plan_tasks_updated_at
BEFORE UPDATE ON plan_tasks
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View file

@ -0,0 +1,51 @@
-- Create trip_plan_status table
CREATE TABLE IF NOT EXISTS trip_plan_status (
id text NOT NULL,
"tripPlanId" text NOT NULL,
status text NOT NULL DEFAULT 'pending',
"currentStep" text,
error text,
"startedAt" timestamp without time zone,
"completedAt" timestamp without time zone,
"createdAt" timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT trip_plan_status_pkey PRIMARY KEY (id)
);
-- Create index on tripPlanId for faster lookups
CREATE INDEX IF NOT EXISTS idx_trip_plan_status_trip_plan_id ON trip_plan_status("tripPlanId");
-- Create trip_plan_output table
CREATE TABLE IF NOT EXISTS trip_plan_output (
id text NOT NULL,
"tripPlanId" text NOT NULL,
itinerary text NOT NULL,
summary text,
"createdAt" timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT trip_plan_output_pkey PRIMARY KEY (id)
);
-- Create index on tripPlanId for faster lookups
CREATE INDEX IF NOT EXISTS idx_trip_plan_output_trip_plan_id ON trip_plan_output("tripPlanId");
-- Create trigger to automatically update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW."updatedAt" = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create trigger for trip_plan_status
CREATE TRIGGER update_trip_plan_status_updated_at
BEFORE UPDATE ON trip_plan_status
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Create trigger for trip_plan_output
CREATE TRIGGER update_trip_plan_output_updated_at
BEFORE UPDATE ON trip_plan_output
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View file

@ -0,0 +1,24 @@
from pydantic import BaseModel, Field
from typing import List, Optional
class FlightResult(BaseModel):
flight_number: str = Field(description="The flight number of the flight")
price: str = Field(description="The price of the flight")
airline: str = Field(description="The airline of the flight")
departure_time: str = Field(description="The departure time of the flight")
arrival_time: str = Field(description="The arrival time of the flight")
duration: str = Field(description="The duration of the flight")
stops: int = Field(description="The number of stops of the flight")
class FlightResults(BaseModel):
flights: List[FlightResult] = Field(description="The list of flights")
class FlightSearchRequest(BaseModel):
departure: str = Field(description="The departure airport")
destination: str = Field(description="The destination airport")
date: str = Field(description="The date of the flight")
return_date: Optional[str] = Field(description="The return date of the flight")
adults: int = Field(description="The number of adults")
children: int = Field(description="The number of children")
cabin_class: str = Field(description="The cabin class")
sort: str = Field(description="The sort order")

View file

@ -0,0 +1,23 @@
from pydantic import BaseModel, Field
from typing import List
class HotelResult(BaseModel):
hotel_name: str = Field(description="The name of the hotel")
price: str = Field(description="The price of the hotel")
rating: str = Field(description="The rating of the hotel")
address: str = Field(description="The address of the hotel")
amenities: List[str] = Field(description="The amenities of the hotel")
description: str = Field(description="The description of the hotel")
url: str = Field(description="The url of the hotel")
class HotelResults(BaseModel):
hotels: List[HotelResult] = Field(description="The list of hotels")
class HotelSearchRequest(BaseModel):
destination: str = Field(description="The destination city or area")
check_in: str = Field(description="The date of check-in in the format 'YYYY-MM-DD'")
check_out: str = Field(description="The date of check-out in the format 'YYYY-MM-DD'")
adults: int = Field(description="The number of adults")
children: int = Field(description="The number of children")
rooms: int = Field(description="The number of rooms")
sort: str = Field(description="The sort order")

View file

@ -0,0 +1,49 @@
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from sqlalchemy import String, DateTime, Enum as SQLEnum, JSON
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class TaskStatus(str, Enum):
queued = "queued"
in_progress = "in_progress"
success = "success"
error = "error"
@classmethod
def _missing_(cls, value):
"""Handle case-insensitive enum values."""
for member in cls:
if member.value.lower() == value.lower():
return member
return None
class Base(DeclarativeBase):
pass
class PlanTask(Base):
"""Model for tracking plan tasks and their states."""
__tablename__ = "plan_tasks"
id: Mapped[int] = mapped_column(primary_key=True)
trip_plan_id: Mapped[str] = mapped_column(String(50), index=True)
task_type: Mapped[str] = mapped_column(String(50))
status: Mapped[TaskStatus] = mapped_column(
SQLEnum(TaskStatus, name="plan_task_status")
)
input_data: Mapped[dict] = mapped_column(JSON)
output_data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
error_message: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View file

@ -0,0 +1,115 @@
from pydantic import BaseModel, Field
from typing import List
from models.hotel import HotelResult
class TravelDates(BaseModel):
start: str = ""
end: str = ""
class TravelPlanRequest(BaseModel):
name: str = ""
destination: str = ""
starting_location: str = ""
travel_dates: TravelDates = TravelDates()
date_input_type: str = "picker"
duration: int = 0
traveling_with: str = ""
adults: int = 1
children: int = 0
age_groups: List[str] = []
budget: int = 75000
budget_currency: str = "INR"
travel_style: str = ""
budget_flexible: bool = False
vibes: List[str] = []
priorities: List[str] = []
interests: str = ""
rooms: int = 1
pace: List[int] = [3]
been_there_before: str = ""
loved_places: str = ""
additional_info: str = ""
class TravelPlanAgentRequest(BaseModel):
trip_plan_id: str
travel_plan: TravelPlanRequest
class TravelPlanResponse(BaseModel):
success: bool
message: str
trip_plan_id: str
class DayByDayPlan(BaseModel):
day: int = Field(
default=0, description="The day number in the itinerary, starting from 0"
)
date: str = Field(
default="", description="The date for this day in YYYY-MM-DD format"
)
morning: str = Field(
default="", description="Description of morning activities and plans"
)
afternoon: str = Field(
default="", description="Description of afternoon activities and plans"
)
evening: str = Field(
default="", description="Description of evening activities and plans"
)
notes: str = Field(
default="",
description="Additional tips, reminders or important information for the day",
)
class Attraction(BaseModel):
name: str = Field(default="", description="Name of the attraction")
description: str = Field(
default="", description="Detailed description of the attraction"
)
class FlightResult(BaseModel):
duration: str = Field(default="", description="Duration of the flight")
price: str = Field(
default="", description="Price of the flight in the local currency"
)
departure_time: str = Field(default="", description="Departure time of the flight")
arrival_time: str = Field(default="", description="Arrival time of the flight")
airline: str = Field(default="", description="Airline of the flight")
flight_number: str = Field(default="", description="Flight number of the flight")
url: str = Field(default="", description="Website or booking URL for the flight")
stops: int = Field(default=0, description="Number of stops in the flight")
class RestaurantResult(BaseModel):
name: str = Field(default="", description="Name of the restaurant")
description: str = Field(default="", description="Description of the restaurant")
location: str = Field(default="", description="Location of the restaurant")
url: str = Field(
default="", description="Website or booking URL for the restaurant"
)
class TravelPlanTeamResponse(BaseModel):
day_by_day_plan: List[DayByDayPlan] = Field(
description="A list of day-by-day plans for the trip"
)
hotels: List[HotelResult] = Field(description="A list of hotels for the trip")
attractions: List[Attraction] = Field(
description="A list of recommended attractions for the trip"
)
flights: List[FlightResult] = Field(description="A list of flights for the trip")
restaurants: List[RestaurantResult] = Field(
description="A list of recommended restaurants for the trip"
)
budget_insights: List[str] = Field(
description="A list of budget insights for the trip"
)
tips: List[str] = Field(
description="A list of tips or recommendations for the trip"
)

View file

@ -0,0 +1,82 @@
from sqlalchemy import Column, String, TIMESTAMP, ForeignKey, Text, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime, timezone
from typing import Optional
from cuid2 import Cuid
CUID_GENERATOR: Cuid = Cuid()
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class TripPlan(Base):
__tablename__ = (
"trip_plan" # Assuming this table exists as per foreign key constraints
)
id = Column(
String, primary_key=True, default=lambda: str(CUID_GENERATOR.generate())
)
# Add other fields for TripPlan if needed for standalone model definition
# For this task, we only need it to satisfy relationship constraints if defined from this end.
class TripPlanStatus(Base):
"""Model for tracking trip plan status."""
__tablename__ = "trip_plan_status"
id: Mapped[str] = mapped_column(
Text, primary_key=True, default=lambda: CUID_GENERATOR.generate()
)
tripPlanId: Mapped[str] = mapped_column(Text, index=True)
status: Mapped[str] = mapped_column(Text, default="pending")
currentStep: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
startedAt: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=False), nullable=True
)
completedAt: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=False), nullable=True
)
createdAt: Mapped[datetime] = mapped_column(
DateTime(timezone=False),
default=lambda: datetime.now(timezone.utc).replace(tzinfo=None),
)
updatedAt: Mapped[datetime] = mapped_column(
DateTime(timezone=False),
default=lambda: datetime.now(timezone.utc).replace(tzinfo=None),
onupdate=lambda: datetime.now(timezone.utc).replace(tzinfo=None),
)
# Relationship (optional, but good practice)
# trip_plan = relationship("TripPlan") # Define TripPlan model if you want to use this relationship
class TripPlanOutput(Base):
"""Model for storing trip plan output."""
__tablename__ = "trip_plan_output"
id: Mapped[str] = mapped_column(
Text, primary_key=True, default=lambda: CUID_GENERATOR.generate()
)
tripPlanId: Mapped[str] = mapped_column(Text, index=True)
itinerary: Mapped[str] = mapped_column(Text)
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
createdAt: Mapped[datetime] = mapped_column(
DateTime(timezone=False),
default=lambda: datetime.now(timezone.utc).replace(tzinfo=None),
)
updatedAt: Mapped[datetime] = mapped_column(
DateTime(timezone=False),
default=lambda: datetime.now(timezone.utc).replace(tzinfo=None),
onupdate=lambda: datetime.now(timezone.utc).replace(tzinfo=None),
)
# Relationship (optional)
# trip_plan = relationship("TripPlan") # Define TripPlan model

View file

@ -0,0 +1,23 @@
[project]
name = "agno-hackathon"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"agno>=1.5.6",
"asyncpg>=0.30.0",
"boto3>=1.38.27",
"cuid2>=2.0.1",
"exa-py>=1.13.1",
"fast-flights>=2.2",
"fastapi>=0.115.12",
"firecrawl-py>=2.7.1",
"google-genai>=1.18.0",
"loguru>=0.7.3",
"mem0ai>=0.1.102",
"pydantic>=2.11.5",
"python-dotenv>=1.1.0",
"sqlalchemy>=2.0.41",
"uvicorn>=0.34.2",
]

View file

@ -0,0 +1,76 @@
from datetime import datetime, timezone
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.plan_task import PlanTask, TaskStatus
from services.db_service import get_db_session
async def create_plan_task(
trip_plan_id: str,
task_type: str,
input_data: dict,
status: TaskStatus = TaskStatus.queued,
) -> PlanTask:
"""Create a new plan task."""
async with get_db_session() as session:
task = PlanTask(
trip_plan_id=trip_plan_id,
task_type=task_type,
status=status,
input_data=input_data,
)
session.add(task)
await session.commit()
await session.refresh(task)
return task
async def update_task_status(
task_id: int,
status: TaskStatus,
output_data: Optional[dict] = None,
error_message: Optional[str] = None,
) -> Optional[PlanTask]:
"""Update the status and output of a plan task."""
async with get_db_session() as session:
result = await session.execute(select(PlanTask).where(PlanTask.id == task_id))
task = result.scalar_one_or_none()
if task:
task.status = status
if output_data is not None:
task.output_data = output_data
if error_message is not None:
task.error_message = error_message
task.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(task)
return task
async def get_task_by_id(task_id: int) -> Optional[PlanTask]:
"""Get a plan task by its ID."""
async with get_db_session() as session:
result = await session.execute(select(PlanTask).where(PlanTask.id == task_id))
return result.scalar_one_or_none()
async def get_tasks_by_trip_plan(trip_plan_id: str) -> List[PlanTask]:
"""Get all tasks for a specific trip plan."""
async with get_db_session() as session:
result = await session.execute(
select(PlanTask).where(PlanTask.trip_plan_id == trip_plan_id)
)
return list(result.scalars().all())
async def get_tasks_by_status(status: TaskStatus) -> List[PlanTask]:
"""Get all tasks with a specific status."""
async with get_db_session() as session:
result = await session.execute(
select(PlanTask).where(PlanTask.status == status)
)
return list(result.scalars().all())

View file

@ -0,0 +1,152 @@
from datetime import datetime, timezone
from typing import Optional, List
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from models.trip_db import TripPlanStatus, TripPlanOutput
from services.db_service import get_db_session
async def create_trip_plan_status(
trip_plan_id: str, status: str = "pending", current_step: Optional[str] = None
) -> TripPlanStatus:
"""Create a new trip plan status entry."""
async with get_db_session() as session:
status_entry = TripPlanStatus(
tripPlanId=trip_plan_id,
status=status,
currentStep=current_step,
createdAt=datetime.now().replace(tzinfo=None),
updatedAt=datetime.now().replace(tzinfo=None),
)
session.add(status_entry)
await session.commit()
await session.refresh(status_entry)
return status_entry
async def get_trip_plan_status(trip_plan_id: str) -> Optional[TripPlanStatus]:
"""Get the status entry for a trip plan."""
async with get_db_session() as session:
result = await session.execute(
select(TripPlanStatus).where(TripPlanStatus.tripPlanId == trip_plan_id)
)
return result.scalar_one_or_none()
async def update_trip_plan_status(
trip_plan_id: str,
status: str,
current_step: Optional[str] = None,
error: Optional[str] = None,
started_at: Optional[datetime] = None,
completed_at: Optional[datetime] = None,
) -> Optional[TripPlanStatus]:
"""Update the status of a trip plan."""
async with get_db_session() as session:
result = await session.execute(
select(TripPlanStatus).where(TripPlanStatus.tripPlanId == trip_plan_id)
)
status_entry = result.scalar_one_or_none()
if status_entry:
status_entry.status = status
if current_step is not None:
status_entry.currentStep = current_step
if error is not None:
status_entry.error = error
if started_at is not None:
status_entry.startedAt = started_at.replace(tzinfo=None)
if completed_at is not None:
status_entry.completedAt = completed_at.replace(tzinfo=None)
status_entry.updatedAt = datetime.now(timezone.utc).replace(tzinfo=None)
await session.commit()
await session.refresh(status_entry)
return status_entry
async def create_trip_plan_output(
trip_plan_id: str, itinerary: str, summary: Optional[str] = None
) -> TripPlanOutput:
"""Create a new trip plan output entry."""
async with get_db_session() as session:
output_entry = TripPlanOutput(
tripPlanId=trip_plan_id,
itinerary=itinerary,
summary=summary,
createdAt=datetime.now(timezone.utc).replace(tzinfo=None),
updatedAt=datetime.now(timezone.utc).replace(tzinfo=None),
)
session.add(output_entry)
await session.commit()
await session.refresh(output_entry)
return output_entry
async def get_trip_plan_output(trip_plan_id: str) -> Optional[TripPlanOutput]:
"""Get the output entry for a trip plan."""
async with get_db_session() as session:
result = await session.execute(
select(TripPlanOutput).where(TripPlanOutput.tripPlanId == trip_plan_id)
)
return result.scalar_one_or_none()
async def update_trip_plan_output(
trip_plan_id: str, itinerary: Optional[str] = None, summary: Optional[str] = None
) -> Optional[TripPlanOutput]:
"""Update the output of a trip plan."""
async with get_db_session() as session:
result = await session.execute(
select(TripPlanOutput).where(TripPlanOutput.tripPlanId == trip_plan_id)
)
output_entry = result.scalar_one_or_none()
if output_entry:
if itinerary is not None:
output_entry.itinerary = itinerary
if summary is not None:
output_entry.summary = summary
output_entry.updatedAt = datetime.now(timezone.utc).replace(tzinfo=None)
await session.commit()
await session.refresh(output_entry)
return output_entry
async def get_all_pending_trip_plans() -> List[TripPlanStatus]:
"""Get all trip plans with pending status."""
async with get_db_session() as session:
result = await session.execute(
select(TripPlanStatus).where(TripPlanStatus.status == "pending")
)
return list(result.scalars().all())
async def get_all_processing_trip_plans() -> List[TripPlanStatus]:
"""Get all trip plans with processing status."""
async with get_db_session() as session:
result = await session.execute(
select(TripPlanStatus).where(TripPlanStatus.status == "processing")
)
return list(result.scalars().all())
async def get_trip_plans_by_status(status: str) -> List[TripPlanStatus]:
"""Get all trip plans with a specific status."""
async with get_db_session() as session:
result = await session.execute(
select(TripPlanStatus).where(TripPlanStatus.status == status)
)
return list(result.scalars().all())
async def delete_trip_plan_outputs(trip_plan_id: str) -> None:
"""Delete all output entries for a given trip plan ID."""
async with get_db_session() as session:
await session.execute(
delete(TripPlanOutput).where(TripPlanOutput.tripPlanId == trip_plan_id)
)
await session.commit()

View file

@ -0,0 +1,84 @@
import asyncio
from fastapi import APIRouter, HTTPException, status
from loguru import logger
from models.travel_plan import TravelPlanAgentRequest, TravelPlanResponse
from models.plan_task import TaskStatus
from services.plan_service import generate_travel_plan
from repository.plan_task_repository import create_plan_task, update_task_status
from typing import List
router = APIRouter(prefix="/api/plan", tags=["Travel Plan"])
@router.post(
"/trigger",
response_model=TravelPlanResponse,
summary="Trigger Trip Craft Agent",
description="Triggers the travel plan agent with the provided travel details",
)
async def trigger_trip_craft_agent(
request: TravelPlanAgentRequest,
) -> TravelPlanResponse:
"""
Trigger the trip craft agent to create a personalized travel itinerary.
Args:
request: Travel plan request containing trip details and plan ID
Returns:
TravelPlanResponse: Success status and trip plan ID
"""
try:
logger.info(f"Triggering travel plan agent for trip ID: {request.trip_plan_id}")
logger.info(f"Travel plan details: {request.travel_plan}")
# Create initial task
task = await create_plan_task(
trip_plan_id=request.trip_plan_id,
task_type="travel_plan_generation",
input_data=request.travel_plan.model_dump(),
)
logger.info(f"Task created: {task.id}")
# Create background task for plan generation
async def generate_plan_with_tracking():
try:
# Update task status to in progress when service starts
await update_task_status(task.id, TaskStatus.in_progress)
logger.info(f"Task updated to in progress: {task.id}")
result = await generate_travel_plan(request)
# Update task with success status and output
await update_task_status(
task.id, TaskStatus.success, output_data={"travel_plan": result}
)
logger.info(f"Task updated to success: {task.id}")
except Exception as e:
logger.error(f"Error generating travel plan: {str(e)}")
# Update task with error status
await update_task_status(
task.id, TaskStatus.error, error_message=str(e)
)
logger.info(f"Task updated to error: {task.id}")
raise
asyncio.create_task(generate_plan_with_tracking())
logger.info(
f"Travel plan agent triggered successfully for trip ID: {request.trip_plan_id}"
)
return TravelPlanResponse(
success=True,
message="Travel plan agent triggered successfully",
trip_plan_id=request.trip_plan_id,
)
except Exception as e:
logger.error(f"Error triggering travel plan agent: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to trigger travel plan agent: {str(e)}",
)

View file

@ -0,0 +1,131 @@
"""
Database service module for PostgreSQL connections using SQLAlchemy.
This module provides utilities for connecting to a PostgreSQL database
with SQLAlchemy, including connection pooling, session management,
and context managers for proper resource management.
"""
import os
from typing import Any, AsyncGenerator, Dict, Optional
from contextlib import asynccontextmanager
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncSession,
create_async_engine,
async_sessionmaker,
AsyncEngine
)
from loguru import logger
# Database connection string from environment variable
# Convert psycopg to SQLAlchemy format if needed
DATABASE_URL = os.getenv("DATABASE_URL", "")
if DATABASE_URL.startswith("postgresql://"):
# Convert to asyncpg format for SQLAlchemy async
DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://")
# Global engine and session factory
_engine: Optional[AsyncEngine] = None
_session_factory: Optional[async_sessionmaker[AsyncSession]] = None
async def initialize_db_pool(pool_size: int = 10, max_overflow: int = 20) -> None:
"""Initialize the SQLAlchemy engine and session factory.
Args:
pool_size: Pool size for the connection pool
max_overflow: Maximum number of connections that can be created beyond the pool size
"""
global _engine, _session_factory
if _engine is not None:
return
logger.info("Initializing SQLAlchemy engine and session factory")
try:
_engine = create_async_engine(
DATABASE_URL,
echo=False, # Set to True for SQL query logging
pool_size=pool_size,
max_overflow=max_overflow,
pool_pre_ping=True, # Verify connections before using them
)
_session_factory = async_sessionmaker(
_engine,
expire_on_commit=False,
autoflush=False,
)
# Test the connection
async with _session_factory() as session:
await session.execute(text("SELECT 1"))
logger.info("SQLAlchemy engine and session factory initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize SQLAlchemy engine and session factory: {e}")
raise
async def close_db_pool() -> None:
"""Close the SQLAlchemy engine and connection pool."""
global _engine
if _engine is not None:
logger.info("Closing SQLAlchemy engine and connection pool")
await _engine.dispose()
_engine = None
@asynccontextmanager
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
"""Get a SQLAlchemy session for database operations.
Returns:
AsyncSession: SQLAlchemy async session
Example:
```python
async with get_db_session() as session:
result = await session.execute(text("SELECT * FROM users"))
users = result.fetchall()
```
"""
if _session_factory is None:
await initialize_db_pool()
async with _session_factory() as session:
try:
yield session
except Exception as e:
await session.rollback()
logger.error(f"Database session operation failed: {e}")
raise
async def execute_query(query: str, params: Optional[Dict[str, Any]] = None) -> list:
"""Execute a database query and return results.
Args:
query: SQL query to execute (raw SQL)
params: Query parameters (for parameterized queries)
Returns:
list: Query results
Example:
```python
results = await execute_query(
"SELECT * FROM users WHERE email = :email",
{"email": "user@example.com"}
)
```
"""
async with get_db_session() as session:
try:
result = await session.execute(text(query), params or {})
try:
return result.fetchall()
except Exception:
# No results to fetch
return []
except Exception as e:
logger.error(f"Failed to execute query: {e}")
raise

View file

@ -0,0 +1,413 @@
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from models.trip_db import TripPlanStatus, TripPlanOutput
from models.travel_plan import (
TravelPlanAgentRequest,
TravelPlanRequest,
TravelPlanTeamResponse,
)
from loguru import logger
from agents.team import trip_planning_team
import json
import time
from agents.structured_output import convert_to_model
from repository.trip_plan_repository import (
create_trip_plan_status,
update_trip_plan_status,
get_trip_plan_status,
create_trip_plan_output,
delete_trip_plan_outputs,
)
from agents.destination import destination_agent
from agents.itinerary import itinerary_agent
from agents.flight import flight_search_agent
from agents.hotel import hotel_search_agent
from agents.food import dining_agent
from agents.budget import budget_agent
def travel_request_to_markdown(data: TravelPlanRequest) -> str:
# Map of travel vibes to their descriptions
travel_vibes = {
"relaxing": "a peaceful retreat focused on wellness, spa experiences, and leisurely activities",
"adventure": "thrilling experiences including hiking, water sports, and adrenaline activities",
"romantic": "intimate experiences with private dining, couples activities, and scenic spots",
"cultural": "immersive experiences with local traditions, museums, and historical sites",
"food-focused": "culinary experiences including cooking classes, food tours, and local cuisine",
"nature": "outdoor experiences with national parks, wildlife, and scenic landscapes",
"photography": "photogenic locations with scenic viewpoints, cultural sites, and natural wonders",
}
# Map of travel styles to their descriptions
travel_styles = {
"backpacker": "budget-friendly accommodations, local transportation, and authentic experiences",
"comfort": "mid-range hotels, convenient transportation, and balanced comfort-value ratio",
"luxury": "premium accommodations, private transfers, and exclusive experiences",
"eco-conscious": "sustainable accommodations, eco-friendly activities, and responsible tourism",
}
# Map of pace levels (0-5) to their descriptions
pace_levels = {
0: "1-2 activities per day with plenty of free time and flexibility",
1: "2-3 activities per day with significant downtime between activities",
2: "3-4 activities per day with balanced activity and rest periods",
3: "4-5 activities per day with moderate breaks between activities",
4: "5-6 activities per day with minimal downtime",
5: "6+ activities per day with back-to-back scheduling",
}
def format_date(date_str: str, is_picker: bool) -> str:
if not date_str:
return "Not specified"
if is_picker:
try:
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
return dt.strftime("%B %d, %Y")
except ValueError:
return date_str
return date_str.strip()
date_type = data.date_input_type
is_picker = date_type == "picker"
start_date = format_date(data.travel_dates.start, is_picker)
end_date = format_date(data.travel_dates.end, is_picker)
date_range = (
f"between {start_date} and {end_date}"
if end_date and end_date != "Not specified"
else start_date
)
vibes = data.vibes
vibes_descriptions = [travel_vibes.get(v, v) for v in vibes]
lines = [
f"# 🧳 Travel Plan Request",
"",
"## 📍 Trip Overview",
f"- **Traveler:** {data.name.title() if data.name else 'Unnamed Traveler'}",
f"- **Route:** {data.starting_location.title()}{data.destination.title()}",
f"- **Duration:** {data.duration} days ({date_range})",
"",
"## 👥 Travel Group",
f"- **Group Size:** {data.adults} adults, {data.children} children",
f"- **Traveling With:** {data.traveling_with or 'Not specified'}",
f"- **Age Groups:** {', '.join(data.age_groups) or 'Not specified'}",
f"- **Rooms Needed:** {data.rooms or 'Not specified'}",
"",
"## 💰 Budget & Preferences",
f"- **Budget per person:** {data.budget} {data.budget_currency} ({'Flexible' if data.budget_flexible else 'Fixed'})",
f"- **Travel Style:** {travel_styles.get(data.travel_style, data.travel_style or 'Not specified')}",
f"- **Preferred Pace:** {', '.join([pace_levels.get(p, str(p)) for p in data.pace]) or 'Not specified'}",
"",
"## ✨ Trip Preferences",
]
if vibes_descriptions:
lines.append("- **Travel Vibes:**")
for vibe in vibes_descriptions:
lines.append(f" - {vibe}")
else:
lines.append("- **Travel Vibes:** Not specified")
if data.priorities:
lines.append(f"- **Top Priorities:** {', '.join(data.priorities)}")
if data.interests:
lines.append(f"- **Interests:** {data.interests}")
lines.extend(
[
"",
"## 🗺️ Destination Context",
f"- **Previous Visit:** {data.been_there_before.capitalize() if data.been_there_before else 'Not specified'}",
f"- **Loved Places:** {data.loved_places or 'Not specified'}",
f"- **Additional Notes:** {data.additional_info or 'Not specified'}",
]
)
return "\n".join(lines)
async def generate_travel_plan(request: TravelPlanAgentRequest) -> str:
"""Generate a travel plan based on the request and log status/output to database."""
trip_plan_id = request.trip_plan_id
logger.info(f"Generating travel plan for tripPlanId: {trip_plan_id}")
# Get or create status entry using repository functions
status_entry = await get_trip_plan_status(trip_plan_id)
if not status_entry:
status_entry = await create_trip_plan_status(
trip_plan_id=trip_plan_id, status="pending"
)
# Update status to processing
status_entry = await update_trip_plan_status(
trip_plan_id=trip_plan_id,
status="processing",
current_step="Initializing travel plan generation",
started_at=datetime.now(timezone.utc),
)
try:
travel_request_md = travel_request_to_markdown(request.travel_plan)
logger.info(f"Travel request markdown: {travel_request_md}")
# Update status for AI team generation
await update_trip_plan_status(
trip_plan_id=trip_plan_id,
status="processing",
current_step="Generating plan with TripCraft AI agents",
)
last_response_content = ""
time_start = time.time()
# Team Collaboration
# prompt = f"""
# Below is my travel plan request. Please generate a travel plan for the request.
# {travel_request_md}
# """
# time_start = time.time()
# ai_response = await trip_planning_team.arun(prompt)
# time_end = time.time()
# logger.info(f"AI team processing time: {time_end - time_start:.2f} seconds")
# last_response_content = ai_response.messages[-1].content
# logger.info(
# f"Last AI Response for conversion: {last_response_content[:500]}..."
# )
# Update status for AI team generation
await update_trip_plan_status(
trip_plan_id=trip_plan_id,
status="processing",
current_step="Researching about the destination",
)
# Destination Research
destionation_research_response = await destination_agent.arun(
f"""
Please research about the destination {request.travel_plan.destination}
Below are user's travel request:
{travel_request_md}
Provide a very detailed research about the destination, its attractions, activities, and other relevant information that user might be interested in.
Give 10 attractions/activities that user might be interested in.
"""
)
logger.info(
f"Destination research response: {destionation_research_response.messages[-1].content}"
)
last_response_content = f"""
## Destination Attractions:
---
{destionation_research_response.messages[-1].content}
---
"""
# Update status for AI team generation
await update_trip_plan_status(
trip_plan_id=trip_plan_id,
status="processing",
current_step="Searching for the best flights",
)
# Flight Search
flight_search_response = await flight_search_agent.arun(
f"""
Please find flights according to the user's travel request:
{travel_request_md}
If user has not specified the exact flight date, please consider it by yourself based on the user's travel request.
Provide a very detailed research about the flights, its price, duration, and other relevant information that user might be interested in.
Give top 5 flights.
"""
)
logger.info(
f"Flight search response: {flight_search_response.messages[-1].content}"
)
last_response_content += f"""
## Flight recommendations:
---
{flight_search_response.messages[-1].content}
---
"""
# Update status for AI team generation
await update_trip_plan_status(
trip_plan_id=trip_plan_id,
status="processing",
current_step="Searching for the best hotels",
)
# Hotel Search
hotel_search_response = await hotel_search_agent.arun(
f"""
Please find hotels according to the user's travel request:
{travel_request_md}
If user has not specified the exact hotel dates, please consider it by yourself based on the user's travel request.
Provide a very detailed research about the hotels, its price, amenities, and other relevant information that user might be interested in.
Give top 5 hotels.
"""
)
last_response_content += f"""
## Hotel recommendations:
---
{hotel_search_response.messages[-1].content}
---
"""
logger.info(
f"Hotel search response: {hotel_search_response.messages[-1].content}"
)
# Update status for AI team generation
await update_trip_plan_status(
trip_plan_id=trip_plan_id,
status="processing",
current_step="Searching for the best restaurants",
)
# Restaurant Search
restaurant_search_response = await dining_agent.arun(
f"""
Please find restaurants according to the user's travel request:
{travel_request_md}
If user has not specified the exact restaurant dates, please consider it by yourself based on the user's travel request.
Provide a very detailed research about the restaurants, its price, menu, and other relevant information that user might be interested in.
Give top 5 restaurants.
"""
)
last_response_content += f"""
## Restaurant recommendations:
---
{restaurant_search_response.messages[-1].content}
---
"""
logger.info(
f"Restaurant search response: {restaurant_search_response.messages[-1].content}"
)
# Update status for AI team generation
await update_trip_plan_status(
trip_plan_id=trip_plan_id,
status="processing",
current_step="Creating the day-by-day itinerary",
)
# Itinerary
itinerary_response = await itinerary_agent.arun(
f"""
Please create a detailed day-by-day itinerary for a trip to {request.travel_plan.destination} for user's travel request:
{travel_request_md}
Based on the following information:
{last_response_content}
"""
)
logger.info(f"Itinerary response: {itinerary_response.messages[-1].content}")
last_response_content += f"""
## Day-by-day itinerary:
---
{itinerary_response.messages[-1].content}
---
"""
# Update status for AI team generation
await update_trip_plan_status(
trip_plan_id=trip_plan_id,
status="processing",
current_step="Optimizing the budget",
)
# Budget
budget_response = await budget_agent.arun(
f"""
Please optimize the budget according to the user's travel request:
{travel_request_md}
Based on the following information:
{last_response_content}
"""
)
logger.info(f"Budget response: {budget_response.messages[-1].content}")
time_end = time.time()
logger.info(f"Total time taken: {time_end - time_start:.2f} seconds")
# Update status for response conversion
await update_trip_plan_status(
trip_plan_id=trip_plan_id,
status="processing",
current_step="Adding finishing touches",
)
json_response_output = await convert_to_model(
last_response_content, TravelPlanTeamResponse
)
logger.info(f"Converted Structured Response: {json_response_output[:500]}...")
# Delete any existing output entries for this trip plan
await delete_trip_plan_outputs(trip_plan_id=trip_plan_id)
final_response = json.dumps(
{
"itinerary": json_response_output,
"budget_agent_response": budget_response.messages[-1].content,
"destination_agent_response": destionation_research_response.messages[
-1
].content,
"flight_agent_response": flight_search_response.messages[-1].content,
"hotel_agent_response": hotel_search_response.messages[-1].content,
"restaurant_agent_response": restaurant_search_response.messages[
-1
].content,
"itinerary_agent_response": itinerary_response.messages[-1].content,
},
indent=2,
)
# Create new output entry
await create_trip_plan_output(
trip_plan_id=trip_plan_id,
itinerary=final_response,
summary="",
)
# Update status to completed
await update_trip_plan_status(
trip_plan_id=trip_plan_id,
status="completed",
current_step="Plan generated and saved",
completed_at=datetime.now(timezone.utc),
)
return final_response
except Exception as e:
logger.error(
f"Error generating travel plan for {trip_plan_id}: {str(e)}", exc_info=True
)
# Update status to failed
await update_trip_plan_status(
trip_plan_id=trip_plan_id,
status="failed",
error=str(e),
completed_at=datetime.now(timezone.utc),
)
raise

View file

@ -0,0 +1,52 @@
from fast_flights import FlightData, Passengers, Result, get_flights
from typing import Literal
from loguru import logger
from agno.tools import tool
from config.logger import logger_hook
@tool(name="get_flights", show_result=True, tool_hooks=[logger_hook])
def get_google_flights(
departure: str,
destination: str,
date: str,
trip: Literal["one-way", "round-trip"] = "one-way",
adults: int = 1,
children: int = 0,
cabin_class: Literal["first", "business", "premium-economy", "economy"] = "economy",
) -> Result:
"""
Get flights from Google Flights
:param departure: The departure airport code
:param destination: The destination airport code
:param date: The date of the flight in the format 'YYYY-MM-DD'
:param trip: The type of trip (one-way, round-trip)
:param adults: The number of adults (default 1)
:param children: The number of children (default 0)
:param cabin_class: The cabin class (first, business, premium-economy, economy)
:return: Flight Results
"""
logger.info(
f"Getting flights from Google Flights for {departure} to {destination} on {date}"
)
try:
result: Result = get_flights(
flight_data=[
FlightData(date=date, from_airport=departure, to_airport=destination)
],
trip=trip,
seat=cabin_class,
passengers=Passengers(
adults=adults, children=children, infants_in_seat=0, infants_on_lap=0
),
fetch_mode="fallback",
)
logger.info(f"Flights found: {result.flights}")
return result.flights
except Exception as e:
logger.error(f"Error getting flights from Google Flights: {e}")
return []

View file

@ -0,0 +1,59 @@
from config.logger import logger_hook
from typing import Optional
from agno.tools import tool
from models.flight import FlightSearchRequest
from loguru import logger
@tool(
name="kayak_flight_url_generator",
show_result=True,
tool_hooks=[logger_hook]
)
def kayak_flight_url_generator(
departure: str, destination: str, date: str, return_date: Optional[str] = None, adults: int = 1, children: int = 0, cabin_class: Optional[str] = None, sort: str = "best"
) -> str:
"""
Generates a Kayak URL for flights between departure and destination on the specified date.
:param departure: The IATA code for the departure airport (e.g., 'SOF' for Sofia)
:param destination: The IATA code for the destination airport (e.g., 'BER' for Berlin)
:param date: The date of the flight in the format 'YYYY-MM-DD'
:return_date: Only for two-way tickets. The date of return flight in the format 'YYYY-MM-DD'
:param adults: The number of adults (default 1)
:param children: The number of children (default 0)
:param cabin_class: The cabin class (first, business, premium, economy)
:param sort: The sort order (best, cheapest)
:return: The Kayak URL for the flight search
"""
request = FlightSearchRequest(
departure=departure,
destination=destination,
date=date,
return_date=return_date,
adults=adults,
children=children,
cabin_class=cabin_class,
sort=sort)
logger.info(f"Request: {request}")
logger.info(f"Generating Kayak URL for {departure} to {destination} on {date}")
URL = f"https://www.kayak.com/flights/{departure}-{destination}/{date}"
if return_date:
URL += f"/{return_date}"
if cabin_class and cabin_class.lower() != "economy":
URL += f"/{cabin_class.lower()}"
URL += f"/{adults}adults"
if children > 0:
URL += f"/children"
for _ in range(children):
URL += "-11"
URL += "?currency=USD"
if sort.lower() == "cheapest":
URL += "&sort=price_a"
elif sort.lower() == "best":
URL += "&sort=bestflight_a"
logger.info(f"URL: {URL}")
return URL

View file

@ -0,0 +1,56 @@
from config.logger import logger_hook
from typing import Optional
from agno.tools import tool
from models.hotel import HotelSearchRequest
from loguru import logger
@tool(
name="kayak_hotel_url_generator",
show_result=True,
tool_hooks=[logger_hook]
)
def kayak_hotel_url_generator(
destination: str, check_in: str, check_out: str, adults: int = 1, children: int = 0, rooms: int = 1, sort: str = "recommended"
) -> str:
"""
Generates a Kayak URL for hotels in the specified destination between check_in and check_out dates.
:param destination: The destination city or area (e.g. "Berlin", "City Center, Singapore", "Red Fort, Delhi")
:param check_in: The date of check-in in the format 'YYYY-MM-DD'
:param check_out: The date of check-out in the format 'YYYY-MM-DD'
:param adults: The number of adults (default 1)
:param children: The number of children (default 0)
:param rooms: The number of rooms (default 1)
:param sort: The sort order (recommended, distance, price, rating)
:return: The Kayak URL for the hotel search
"""
request = HotelSearchRequest(
destination=destination,
check_in=check_in,
check_out=check_out,
adults=adults,
children=children,
rooms=rooms,
sort=sort)
logger.info(f"Request: {request}")
logger.info(f"Generating Kayak URL for {destination} on {check_in} to {check_out}")
URL = f"https://www.kayak.com/hotels/{destination}/{check_in}/{check_out}"
URL += f"/{adults}adults"
if children > 0:
URL += f"/{children}children"
if rooms > 1:
URL += f"/{rooms}rooms"
URL += "?currency=USD"
if sort.lower() == "price":
URL += "&sort=price_a"
elif sort.lower() == "rating":
URL += "&sort=userrating_b"
elif sort.lower() == "distance":
URL += "&sort=distance_a"
logger.info(f"URL: {URL}")
return URL

View file

@ -0,0 +1,34 @@
from firecrawl import FirecrawlApp, ScrapeOptions
import os
from agno.tools import tool
from loguru import logger
from config.logger import logger_hook
app = FirecrawlApp(api_key=os.getenv("FIRECRAWL_API_KEY"))
@tool(
name="scrape_website",
description="Scrape a website and return the markdown content.",
tool_hooks=[logger_hook],
)
def scrape_website(url: str) -> str:
"""Scrape a website and return the markdown content.
Args:
url (str): The URL of the website to scrape.
Returns:
str: The markdown content of the website.
Example:
>>> scrape_website("https://www.google.com")
"## Google"
"""
scrape_status = app.scrape_url(
url,
formats=["markdown"],
wait_for=30000,
timeout=60000,
)
return scrape_status.markdown

View file

@ -0,0 +1,14 @@
from fast_flights import FlightData, Passengers, Result, get_flights
result: Result = get_flights(
flight_data=[FlightData(date="2025-07-01", from_airport="BOM", to_airport="DEL")],
trip="one-way",
seat="economy",
passengers=Passengers(adults=2, children=1, infants_in_seat=0, infants_on_lap=0),
fetch_mode="fallback",
)
print(result)
# The price is currently... low/typical/high
print("The price is currently", result.flights)

View file

@ -0,0 +1,136 @@
---
description:
globs:
alwaysApply: true
---
# Cursor Rules - Modern Web Development
You are an expert senior software engineer specializing in modern web development, with deep expertise in TypeScript, React 19, Next.js 15 (App Router), Vercel AI SDK, Shadcn UI, Radix UI, and Tailwind CSS. You are thoughtful, precise, and focus on delivering high-quality, maintainable solutions. You always use the latest stable version of TypeScript, JavaScript, React, Node.js, Next.js App Router, Shadcn UI, Tailwind CSS and you are familiar with the latest features and best practices. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning AI to chat, to generate code.
## Package Management
- Use **pnpm** as the primary package manager for all projects
- Leverage pnpm's workspace features for monorepo structures
- Use `pnpm dlx` for one-time package executions
- Configure `.npmrc` with appropriate settings for optimal performance
## Shadcn UI Integration
- Use **Shadcn UI** as the primary component library
- Initialize projects with `pnpm dlx shadcn@latest init`
- Add components with `pnpm dlx shadcn@latest add [component-name]`
- Customize components in `components/ui/` directory
- Leverage Shadcn's built-in accessibility features and Radix UI primitives
- Use Shadcn's theming system with CSS variables
## Analysis Process
Before responding to any request, follow these steps:
### 1. Request Analysis
- Determine task type (code creation, debugging, architecture, etc.)
- Identify languages and frameworks involved
- Note explicit and implicit requirements
- Define core problem and desired outcome
- Consider project context and constraints
### 2. Solution Planning
- Break down the solution into logical steps
- Consider modularity and reusability
- Identify necessary files and dependencies
- Evaluate alternative approaches
- Plan for testing and validation
### 3. Implementation Strategy
- Choose appropriate design patterns
- Consider performance implications
- Plan for error handling and edge cases
- Ensure accessibility compliance
- Verify best practices alignment
## Performance Optimization
- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC)
- Wrap client components in Suspense with fallback
- Use dynamic loading for non-critical components
- Optimize images: use WebP format, include size data, implement lazy loading
- Leverage Next.js 15 App Router features for optimal performance
## UI and Styling
- Use **Shadcn UI** as the primary component system
- Extend Shadcn components when additional functionality is needed
- Use Radix UI primitives for complex interactive components
- Implement Tailwind CSS for custom styling with a mobile-first approach
- Follow Shadcn's theming conventions for consistent design
- Use Tailwind Aria for accessibility enhancements
## TypeScript Usage
- Use TypeScript for all code; prefer interfaces over types
- Avoid enums; use maps or const assertions instead
- Use functional components with TypeScript interfaces
- Implement strict type checking and proper generic usage
- Use Zod for runtime type validation
## Syntax and Formatting
- Use the "function" keyword for pure functions
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements
- Use declarative JSX
- Follow consistent formatting with Prettier integration
## Error Handling and Validation
- Prioritize error handling: handle errors and edge cases early
- Use early returns and guard clauses
- Implement proper error logging and user-friendly messages
- Use **Zod** for form validation and schema definition
- Model expected errors as return values in Server Actions
- Use error boundaries for unexpected errors
- Implement proper loading and error states in UI components
## Code Style and Structure
- Write concise, technical TypeScript code with accurate examples
- Use functional and declarative programming patterns; avoid classes
- Prefer iteration and modularization over code duplication
- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError)
- Structure files: exported component, subcomponents, helpers, static content, types
## File Organization
- Use lowercase with dashes for directories (e.g., `components/auth-wizard`)
- Favor named exports for components
- Follow Next.js 15 App Router file conventions
- Organize Shadcn UI components in `components/ui/`
- Keep custom components in `components/` with appropriate subdirectories
## Project Setup Commands
```bash
# Initialize new Next.js project with pnpm
pnpm create next-app@latest my-app --typescript --tailwind --eslint --app
# Initialize Shadcn UI
pnpm dlx shadcn-ui@latest init
# Add Shadcn components
pnpm dlx shadcn-ui@latest add button input form
# Install additional dependencies
pnpm add zod @hookform/resolvers
# Development commands
pnpm dev
pnpm build
pnpm start
```
## Best Practices
- Always use Server Components by default, only add 'use client' when necessary
- Implement proper SEO with Next.js metadata API
- Use Shadcn's form components with react-hook-form and Zod
- Leverage Next.js 15's enhanced caching and performance features
- Follow accessibility guidelines with Shadcn's built-in a11y features
- Implement proper error boundaries and loading states
- Use TypeScript strict mode and proper type definitions

View file

@ -0,0 +1,57 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# prisma
/prisma/.env
.env
.env.*
!.env.example
prisma/generated/
# Prisma generated client
/lib/generated/prisma
# Important: DO NOT ignore migration files as they're needed for production deployments
!prisma/migrations/
.vscode/

View file

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View file

@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
export const GET = auth.handler;
export const POST = auth.handler;

View file

@ -0,0 +1,155 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
interface TripFormData {
name: string;
destination: string;
startingLocation: string;
travelDates: { start: string; end: string };
dateInputType: "picker" | "text";
duration: number;
travelingWith: string;
adults: number;
children: number;
ageGroups: string[];
budget: number;
budgetCurrency: string;
travelStyle: string;
budgetFlexible: boolean;
vibes: string[];
priorities: string[];
interests?: string;
rooms: number;
pace: number[];
beenThereBefore?: string;
lovedPlaces?: string;
additionalInfo?: string;
}
export async function POST(request: NextRequest) {
try {
const tripData: TripFormData = await request.json();
// Log the trip data for debugging
console.log('Received trip planning data:', JSON.stringify(tripData, null, 2));
// Validate required fields
if (!tripData.name || !tripData.destination || !tripData.startingLocation) {
return NextResponse.json(
{
success: false,
message: 'Missing required fields: name, destination, or starting location'
},
{ status: 400 }
);
}
// Save to database
const savedTripPlan = await prisma.tripPlan.create({
data: {
name: tripData.name,
destination: tripData.destination,
startingLocation: tripData.startingLocation,
travelDatesStart: tripData.travelDates.start,
travelDatesEnd: tripData.travelDates.end || null,
dateInputType: tripData.dateInputType || "picker",
duration: tripData.duration || null,
travelingWith: tripData.travelingWith,
adults: tripData.adults || 1,
children: tripData.children || 0,
ageGroups: tripData.ageGroups || [],
budget: tripData.budget,
budgetCurrency: tripData.budgetCurrency || "USD",
travelStyle: tripData.travelStyle,
budgetFlexible: tripData.budgetFlexible || false,
vibes: tripData.vibes || [],
priorities: tripData.priorities || [],
interests: tripData.interests || null,
rooms: tripData.rooms || 1,
pace: tripData.pace || [3],
beenThereBefore: tripData.beenThereBefore || null,
lovedPlaces: tripData.lovedPlaces || null,
additionalInfo: tripData.additionalInfo || null,
// userId can be added later when auth is implemented
userId: null
}
});
console.log('Trip plan saved to database:', savedTripPlan.id);
const requestBody = {
trip_plan_id: savedTripPlan.id,
travel_plan: {
name: tripData.name,
destination: tripData.destination,
starting_location: tripData.startingLocation,
travel_dates: {
start: tripData.travelDates.start,
end: tripData.travelDates.end || ""
},
date_input_type: tripData.dateInputType,
duration: tripData.duration,
traveling_with: tripData.travelingWith,
adults: tripData.adults,
children: tripData.children,
age_groups: tripData.ageGroups,
budget: tripData.budget,
budget_currency: tripData.budgetCurrency,
travel_style: tripData.travelStyle,
budget_flexible: tripData.budgetFlexible,
vibes: tripData.vibes,
priorities: tripData.priorities,
interests: tripData.interests || "",
rooms: tripData.rooms,
pace: tripData.pace,
been_there_before: tripData.beenThereBefore || "",
loved_places: tripData.lovedPlaces || "",
additional_info: tripData.additionalInfo || ""
}
}
console.log('Request body:', JSON.stringify(requestBody, null, 2));
// Call backend API to trigger trip planning
const backendResponse = await fetch(`${process.env.BACKEND_API_URL}/api/plan/trigger`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
if (!backendResponse.ok) {
console.error('Backend API error:', await backendResponse.text());
return NextResponse.json(
{
success: false,
message: 'Failed to trigger trip planning'
},
{ status: 500 }
);
}
const responseData = await backendResponse.json();
console.log('Backend response:', JSON.stringify(responseData, null, 2));
return NextResponse.json(
{
success: true,
message: 'Trip planning triggered successfully',
response: responseData,
tripPlanId: savedTripPlan.id
},
{ status: 200 }
);
} catch (error) {
console.error('Error processing trip submission:', error);
return NextResponse.json(
{
success: false,
message: 'Failed to save trip plan to database'
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// First check if the plan exists
const tripPlan = await prisma.tripPlan.findUnique({
where: { id },
include: {
status: true,
output: true,
},
});
if (!tripPlan) {
return NextResponse.json(
{
success: false,
message: 'Trip plan not found'
},
{ status: 404 }
);
}
// Update the status to pending/processing
await prisma.tripPlanStatus.upsert({
where: { tripPlanId: id },
update: {
status: 'processing',
currentStep: 'Restarting trip plan generation...',
},
create: {
tripPlanId: id,
status: 'processing',
currentStep: 'Restarting trip plan generation...',
},
});
// Prepare the request body for the backend API
const requestBody = {
trip_plan_id: id,
travel_plan: {
name: tripPlan.name,
destination: tripPlan.destination,
starting_location: tripPlan.startingLocation,
travel_dates: {
start: tripPlan.travelDatesStart,
end: tripPlan.travelDatesEnd || ""
},
date_input_type: tripPlan.dateInputType,
duration: tripPlan.duration,
traveling_with: tripPlan.travelingWith,
adults: tripPlan.adults,
children: tripPlan.children,
age_groups: tripPlan.ageGroups,
budget: tripPlan.budget,
budget_currency: tripPlan.budgetCurrency,
travel_style: tripPlan.travelStyle,
budget_flexible: tripPlan.budgetFlexible,
vibes: tripPlan.vibes,
priorities: tripPlan.priorities,
interests: tripPlan.interests || "",
rooms: tripPlan.rooms,
pace: tripPlan.pace,
been_there_before: tripPlan.beenThereBefore || "",
loved_places: tripPlan.lovedPlaces || "",
additional_info: tripPlan.additionalInfo || ""
}
};
// Call backend API to trigger trip planning again
const backendResponse = await fetch(`${process.env.BACKEND_API_URL}/api/plan/trigger`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
if (!backendResponse.ok) {
// If backend call fails, update status back to failed
await prisma.tripPlanStatus.update({
where: { tripPlanId: id },
data: {
status: 'failed',
currentStep: 'Failed to restart trip plan generation',
},
});
console.error('Backend API error:', await backendResponse.text());
return NextResponse.json(
{
success: false,
message: 'Failed to retry trip planning'
},
{ status: 500 }
);
}
const responseData = await backendResponse.json();
console.log('Backend retry response:', JSON.stringify(responseData, null, 2));
return NextResponse.json(
{
success: true,
message: 'Trip planning retry triggered successfully',
response: responseData
},
{ status: 200 }
);
} catch (error) {
console.error('Error processing trip retry:', error);
// Ensure we update the status to failed if there's an error
try {
await prisma.tripPlanStatus.update({
where: { tripPlanId: params.id },
data: {
status: 'failed',
currentStep: 'Error occurred while retrying',
},
});
} catch (statusError) {
console.error('Failed to update status after error:', statusError);
}
return NextResponse.json(
{
success: false,
message: 'Failed to retry trip plan'
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,101 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const { id } = await params;
const tripPlan = await prisma.tripPlan.findUnique({
where: { id },
include: {
status: true,
output: true,
},
});
if (!tripPlan) {
return NextResponse.json(
{
success: false,
message: 'Trip plan not found'
},
{ status: 404 }
);
}
return NextResponse.json(
{
success: true,
tripPlan
},
{ status: 200 }
);
} catch (error) {
console.error('Error fetching trip plan:', error);
return NextResponse.json(
{
success: false,
message: 'Failed to fetch trip plan'
},
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// First check if the plan exists
const tripPlan = await prisma.tripPlan.findUnique({
where: { id },
});
if (!tripPlan) {
return NextResponse.json(
{
success: false,
message: 'Trip plan not found'
},
{ status: 404 }
);
}
// Delete related records first (status and output)
await prisma.tripPlanStatus.deleteMany({
where: { tripPlanId: id },
});
await prisma.tripPlanOutput.deleteMany({
where: { tripPlanId: id },
});
// Delete the trip plan
await prisma.tripPlan.delete({
where: { id },
});
return NextResponse.json(
{
success: true,
message: 'Trip plan deleted successfully'
},
{ status: 200 }
);
} catch (error) {
console.error('Error deleting trip plan:', error);
return NextResponse.json(
{
success: false,
message: 'Failed to delete trip plan'
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const tripPlans = await prisma.tripPlan.findMany({
orderBy: {
createdAt: 'desc'
}
});
return NextResponse.json(
{
success: true,
tripPlans
},
{ status: 200 }
);
} catch (error) {
console.error('Error fetching trip plans:', error);
return NextResponse.json(
{
success: false,
message: 'Failed to fetch trip plans'
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,204 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner";
export default function AuthPage() {
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
});
const router = useRouter();
function handleInputChange(field: string, value: string) {
setFormData((prev) => ({ ...prev, [field]: value }));
}
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setIsLoading(true);
try {
const result = await authClient.signIn.email({
email: formData.email,
password: formData.password,
});
if (result.error) {
toast.error(result.error.message || "Failed to sign in");
return;
}
toast.success("Welcome back! Redirecting to your dashboard...");
router.push("/plan");
} catch (error) {
toast.error("An unexpected error occurred");
console.error("Sign in failed:", error);
} finally {
setIsLoading(false);
}
}
async function handleSignUp(e: React.FormEvent) {
e.preventDefault();
setIsLoading(true);
try {
const result = await authClient.signUp.email({
email: formData.email,
password: formData.password,
name: formData.name,
});
if (result.error) {
toast.error(result.error.message || "Failed to create account");
return;
}
toast.success("Account created successfully! Redirecting...");
router.push("/plan");
} catch (error) {
toast.error("An unexpected error occurred");
console.error("Sign up failed:", error);
} finally {
setIsLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
Welcome
</CardTitle>
<CardDescription className="text-center">
Sign in to your account or create a new one
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="signin" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="signin">Sign In</TabsTrigger>
<TabsTrigger value="signup">Sign Up</TabsTrigger>
</TabsList>
<TabsContent value="signin" className="space-y-4">
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Email
</label>
<Input
id="email"
type="email"
placeholder="Enter your email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label
htmlFor="password"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Password
</label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={formData.password}
onChange={(e) =>
handleInputChange("password", e.target.value)
}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign In"}
</Button>
</form>
</TabsContent>
<TabsContent value="signup" className="space-y-4">
<form onSubmit={handleSignUp} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="name"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Name
</label>
<Input
id="name"
type="text"
placeholder="Enter your name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label
htmlFor="signup-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Email
</label>
<Input
id="signup-email"
type="email"
placeholder="Enter your email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label
htmlFor="signup-password"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Password
</label>
<Input
id="signup-password"
type="password"
placeholder="Create a password"
value={formData.password}
onChange={(e) =>
handleInputChange("password", e.target.value)
}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Creating account..." : "Sign Up"}
</Button>
</form>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,198 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: DM Sans, sans-serif;
--font-mono: Space Mono, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--radius: 0px;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
}
:root {
--radius: 0px;
--background: oklch(1 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.6489 0.237 26.9728);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.968 0.211 109.7692);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.5635 0.2408 260.8178);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0 0 0);
--border: oklch(0 0 0);
--input: oklch(0 0 0);
--ring: oklch(0.6489 0.237 26.9728);
--chart-1: oklch(0.6489 0.237 26.9728);
--chart-2: oklch(0.968 0.211 109.7692);
--chart-3: oklch(0.5635 0.2408 260.8178);
--chart-4: oklch(0.7323 0.2492 142.4953);
--chart-5: oklch(0.5931 0.2726 328.3634);
--sidebar: oklch(0.9551 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.6489 0.237 26.9728);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.6489 0.237 26.9728);
--destructive-foreground: oklch(1 0 0);
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 1;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-offset-x: 4px;
--shadow-offset-y: 4px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.5);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.5);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1),
4px 1px 2px -1px hsl(0 0% 0% / 1);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1), 4px 1px 2px -1px hsl(0 0% 0% / 1);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1),
4px 2px 4px -1px hsl(0 0% 0% / 1);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1),
4px 4px 6px -1px hsl(0 0% 0% / 1);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1),
4px 8px 10px -1px hsl(0 0% 0% / 1);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.5);
--tracking-normal: 0em;
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.3211 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.3211 0 0);
--popover-foreground: oklch(1 0 0);
--primary: oklch(0.7044 0.1872 23.1858);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.9691 0.2005 109.6228);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.3211 0 0);
--muted-foreground: oklch(0.8452 0 0);
--accent: oklch(0.6755 0.1765 252.2592);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(1 0 0);
--border: oklch(1 0 0);
--input: oklch(1 0 0);
--ring: oklch(0.7044 0.1872 23.1858);
--chart-1: oklch(0.7044 0.1872 23.1858);
--chart-2: oklch(0.9691 0.2005 109.6228);
--chart-3: oklch(0.6755 0.1765 252.2592);
--chart-4: oklch(0.7395 0.2268 142.8504);
--chart-5: oklch(0.6131 0.2458 328.0714);
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(0.7044 0.1872 23.1858);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.6755 0.1765 252.2592);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(1 0 0);
--sidebar-ring: oklch(0.7044 0.1872 23.1858);
--destructive-foreground: oklch(0 0 0);
--radius: 0px;
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 1;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-offset-x: 4px;
--shadow-offset-y: 4px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.5);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.5);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1),
4px 1px 2px -1px hsl(0 0% 0% / 1);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1), 4px 1px 2px -1px hsl(0 0% 0% / 1);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1),
4px 2px 4px -1px hsl(0 0% 0% / 1);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1),
4px 4px 6px -1px hsl(0 0% 0% / 1);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1),
4px 8px 10px -1px hsl(0 0% 0% / 1);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.5);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}

View file

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { DM_Sans } from "next/font/google";
import "./globals.css";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { Toaster } from "@/components/ui/sonner";
const dmSans = DM_Sans({
variable: "--font-dm-sans",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "TripCraft AI",
description: "Your Journey, Perfectly Crafted with Intelligence",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${dmSans.variable} antialiased`}>
<Header />
{children}
<Footer />
<Toaster />
</body>
</html>
);
}

View file

@ -0,0 +1,217 @@
import {
MapPin,
Zap,
Heart,
Star,
Plane,
Calendar,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import Link from "next/link";
export default function Home() {
return (
<div className="min-h-screen bg-background">
{/* Hero Section */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center">
<h1 className="text-4xl font-bold sm:text-6xl">
<span className="text-accent">TripCraft AI</span>
</h1>
<h2 className="text-2xl font-semibold sm:text-3xl mt-4 text-muted-foreground">
Your Journey, Perfectly Crafted with Intelligence
</h2>
<p className="mt-6 text-lg leading-8 text-secondary-foreground max-w-3xl mx-auto">
Stop juggling dozens of tabs and conflicting travel info. Our
AI-powered platform turns your travel dreams into realitycomplete
with flights, hotels, activities, and budgetall from a simple
conversation about your perfect trip.
</p>
<div className="mt-10 flex items-center justify-center gap-x-6">
<Link href="/plan">
<Button size="lg" className="bg-primary hover:bg-primary/90">
<Plane className="w-4 h-4 mr-2" />
Plan My Trip
</Button>
</Link>
<Button variant="ghost" size="lg">
See How It Works <span aria-hidden="true"></span>
</Button>
</div>
</div>
{/* How It Works Section */}
<div className="mt-20">
<h3 className="text-3xl font-bold text-center mb-12">How It Works</h3>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
<div className="text-center">
<div className="flex items-center justify-center w-16 h-16 bg-primary/10 rounded-full mb-6 mx-auto border-2 border-primary/20">
<span className="text-2xl font-bold text-primary">1</span>
</div>
<h4 className="text-xl font-semibold mb-4">
Fill Once, Dream Big
</h4>
<p className="text-muted-foreground">
Tell us about your ideal tripdestination, dates, style, budget,
and preferences. Our thoughtful form captures everything in
minutes.
</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center w-16 h-16 bg-secondary/20 rounded-full mb-6 mx-auto border-2 border-secondary/40">
<span className="text-2xl font-bold text-foreground">2</span>
</div>
<h4 className="text-xl font-semibold mb-4">
AI Agents Take Over
</h4>
<p className="text-muted-foreground">
Specialized AI agents work together on flights, lodging,
activities, and budgetingall happening seamlessly in the
background.
</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center w-16 h-16 bg-accent/10 rounded-full mb-6 mx-auto border-2 border-accent/20">
<span className="text-2xl font-bold text-accent">3</span>
</div>
<h4 className="text-xl font-semibold mb-4">
Complete Itinerary Ready
</h4>
<p className="text-muted-foreground">
Get a full day-by-day plan with flights, accommodations,
activities, costs, and booking linksall beautifully organized.
</p>
</div>
</div>
</div>
{/* Feature Cards */}
<div className="mt-20">
<h3 className="text-3xl font-bold text-center mb-12">
Why Choose TripCraft AI
</h3>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
<Card className="hover:shadow-lg transition-shadow border-primary/20 hover:border-primary/40">
<CardHeader>
<div className="flex items-center justify-center w-12 h-12 bg-primary/10 rounded-lg mb-4">
<Sparkles className="w-6 h-6 text-primary" />
</div>
<CardTitle className="text-lg">
AI-Powered Intelligence
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>
Multi-agent AI system that understands your travel style and
crafts personalized itineraries that feel like they were made
just for you.
</CardDescription>
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow border-accent/20 hover:border-accent/40">
<CardHeader>
<div className="flex items-center justify-center w-12 h-12 bg-accent/10 rounded-lg mb-4">
<MapPin className="w-6 h-6 text-accent" />
</div>
<CardTitle className="text-lg">Hidden Gems Discovery</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>
Go beyond tourist traps. We find unique experiences, local
events, and offbeat attractions that match your interests
perfectly.
</CardDescription>
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow border-secondary/30 hover:border-secondary/50">
<CardHeader>
<div className="flex items-center justify-center w-12 h-12 bg-secondary/20 rounded-lg mb-4">
<Zap className="w-6 h-6 text-foreground" />
</div>
<CardTitle className="text-lg">Instant Planning</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>
No more hours of research and comparison. Get a complete
travel plan in moments, with everything balanced perfectly for
your needs.
</CardDescription>
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow border-primary/20 hover:border-primary/40">
<CardHeader>
<div className="flex items-center justify-center w-12 h-12 bg-primary/10 rounded-lg mb-4">
<Star className="w-6 h-6 text-primary" />
</div>
<CardTitle className="text-lg">Smart Memory</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>
Learns from your preferences over time. Each trip becomes more
tailored as our AI remembers what you love.
</CardDescription>
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow border-accent/20 hover:border-accent/40">
<CardHeader>
<div className="flex items-center justify-center w-12 h-12 bg-accent/10 rounded-lg mb-4">
<Calendar className="w-6 h-6 text-accent" />
</div>
<CardTitle className="text-lg">Complete Coordination</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>
Flights, hotels, activities, and budgetall coordinated
seamlessly. No conflicts, no stress, just a perfect plan ready
to execute.
</CardDescription>
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow border-secondary/30 hover:border-secondary/50">
<CardHeader>
<div className="flex items-center justify-center w-12 h-12 bg-secondary/20 rounded-lg mb-4">
<Heart className="w-6 h-6 text-foreground" />
</div>
<CardTitle className="text-lg">Crafted with Care</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>
Every detail is thoughtfully considered to create not just a
trip, but an experience that feels truly magical and personal.
</CardDescription>
</CardContent>
</Card>
</div>
</div>
{/* CTA Section */}
<div className="mt-20 text-center bg-primary/5 rounded-2xl py-16 px-8 border border-primary/10">
<h3 className="text-3xl font-bold mb-6">
Ready to Make Travel Planning Magical?
</h3>
<p className="text-lg text-muted-foreground mb-8 max-w-2xl mx-auto">
Stop spending hours planning and start experiencing. Let our AI
create your perfect journey.
</p>
<Button size="lg" className="bg-primary hover:bg-primary/90">
<Plane className="w-4 h-4 mr-2" />
Start Planning Now
</Button>
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,14 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Plan My Trip - TripCraft AI",
description: "Your Journey, Perfectly Crafted with Intelligence",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return children;
}

View file

@ -0,0 +1,389 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
MapPin,
Calendar as CalendarIcon,
Users,
DollarSign,
Heart,
Home,
Clock,
Globe,
Plane,
Luggage,
Plus,
RefreshCw,
AlertCircle,
Trash2,
Eye,
} from "lucide-react";
import { format } from "date-fns";
import Link from "next/link";
import { toast } from "sonner";
interface TripPlan {
id: string;
name: string;
destination: string;
startingLocation: string;
travelDatesStart: string;
travelDatesEnd?: string;
dateInputType: string;
duration?: number;
travelingWith: string;
adults: number;
children: number;
ageGroups: string[];
budget: number;
budgetCurrency: string;
travelStyle: string;
budgetFlexible: boolean;
vibes: string[];
priorities: string[];
interests?: string;
rooms: number;
pace: number[];
beenThereBefore?: string;
lovedPlaces?: string;
additionalInfo?: string;
createdAt: string;
updatedAt: string;
userId?: string;
}
const formatCurrency = (amount: number, currency: string) => {
const symbols: Record<string, string> = {
USD: "$",
EUR: "€",
GBP: "£",
INR: "₹",
JPY: "¥",
};
return `${symbols[currency] || "$"}${amount.toLocaleString()}`;
};
const formatDate = (dateString: string, inputType: string) => {
if (inputType === "text" || !dateString) {
return dateString || "Flexible dates";
}
try {
return format(new Date(dateString), "MMM dd, yyyy");
} catch {
return dateString;
}
};
const getPaceDescription = (pace: number[]) => {
const paceValue = pace[0] || 3;
const descriptions = {
1: "Very relaxed",
2: "Mostly relaxed",
3: "Balanced",
4: "Quite busy",
5: "Action-packed",
};
return descriptions[paceValue as keyof typeof descriptions] || "Balanced";
};
export default function Plans() {
const [tripPlans, setTripPlans] = useState<TripPlan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deletingPlanId, setDeletingPlanId] = useState<string | null>(null);
const fetchTripPlans = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch("/api/plans");
const data = await response.json();
if (data.success) {
setTripPlans(data.tripPlans);
} else {
setError(data.message || "Failed to fetch trip plans");
}
} catch (err) {
console.error("Error fetching trip plans:", err);
setError("Failed to fetch trip plans");
} finally {
setLoading(false);
}
};
const deleteTripPlan = async (planId: string) => {
try {
setDeletingPlanId(planId);
const response = await fetch(`/api/plans/${planId}`, {
method: "DELETE",
});
const data = await response.json();
if (data.success) {
// Remove the plan from the local state
setTripPlans(tripPlans.filter((plan) => plan.id !== planId));
toast.success("Trip plan deleted successfully");
} else {
toast.error(data.message || "Failed to delete trip plan");
}
} catch (err) {
console.error("Error deleting trip plan:", err);
toast.error("Failed to delete trip plan");
} finally {
setDeletingPlanId(null);
}
};
const handleDeletePlan = (planId: string) => {
if (
window.confirm(
"Are you sure you want to delete this trip plan? This action cannot be undone."
)
) {
deleteTripPlan(planId);
}
};
useEffect(() => {
fetchTripPlans();
}, []);
if (loading) {
return (
<div className="min-h-screen bg-background py-8 px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center">
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
<p className="text-muted-foreground">Loading your trip plans...</p>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-background py-8 px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center">
<AlertCircle className="w-8 h-8 text-destructive mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Error Loading Plans</h2>
<p className="text-muted-foreground mb-4">{error}</p>
<Button onClick={fetchTripPlans} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
Try Again
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background py-8 px-4">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-foreground mb-4 flex items-center justify-center gap-3">
<Luggage className="w-8 h-8 text-primary" />
Your Trip Plans
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Manage and review all your planned adventures
</p>
</div>
{/* Action Bar */}
<div className="flex justify-between items-center mb-8">
<div className="text-sm text-muted-foreground">
{tripPlans.length} {tripPlans.length === 1 ? "plan" : "plans"} found
</div>
<div className="flex gap-3">
<Button onClick={fetchTripPlans} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<Link href="/plan">
<Button>
<Plus className="w-4 h-4 mr-2" />
New Trip Plan
</Button>
</Link>
</div>
</div>
{/* Trip Plans Grid */}
{tripPlans.length === 0 ? (
<div className="text-center py-16">
<Globe className="w-16 h-16 text-muted-foreground/50 mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">No trip plans yet</h3>
<p className="text-muted-foreground mb-6">
Start planning your next adventure!
</p>
<Link href="/plan">
<Button>
<Plus className="w-4 h-4 mr-2" />
Create Your First Trip Plan
</Button>
</Link>
</div>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{tripPlans.map((plan) => (
<Card
key={plan.id}
className="shadow-lg border hover:shadow-xl transition-shadow"
>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<MapPin className="w-5 h-5 text-primary" />
{plan.destination}
</CardTitle>
<CardDescription className="text-base font-medium">
{plan.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Travel Details */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<Plane className="w-4 h-4 text-muted-foreground" />
<span>From {plan.startingLocation}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<span>
{formatDate(plan.travelDatesStart, plan.dateInputType)}
{plan.travelDatesEnd &&
plan.dateInputType === "picker" && (
<>
{" - "}
{formatDate(
plan.travelDatesEnd,
plan.dateInputType
)}
</>
)}
</span>
</div>
{plan.duration && (
<div className="flex items-center gap-2 text-sm">
<Clock className="w-4 h-4 text-muted-foreground" />
<span>{plan.duration} days</span>
</div>
)}
<div className="flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-muted-foreground" />
<span>
{plan.adults} adult{plan.adults > 1 ? "s" : ""}
{plan.children > 0 &&
`, ${plan.children} child${
plan.children > 1 ? "ren" : ""
}`}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<DollarSign className="w-4 h-4 text-muted-foreground" />
<span>
{formatCurrency(plan.budget, plan.budgetCurrency)} per
person
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Home className="w-4 h-4 text-muted-foreground" />
<span>
{plan.rooms} room{plan.rooms > 1 ? "s" : ""},{" "}
{plan.travelStyle}
</span>
</div>
</div>
{/* Vibes */}
{plan.vibes.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Heart className="w-4 h-4" />
Trip Vibes
</h4>
<div className="flex flex-wrap gap-1">
{plan.vibes.slice(0, 3).map((vibe) => (
<Badge
key={vibe}
variant="secondary"
className="text-xs"
>
{vibe}
</Badge>
))}
{plan.vibes.length > 3 && (
<Badge variant="outline" className="text-xs">
+{plan.vibes.length - 3} more
</Badge>
)}
</div>
</div>
)}
{/* Pace */}
<div className="text-sm text-muted-foreground">
<span className="font-medium">Pace:</span>{" "}
{getPaceDescription(plan.pace)}
</div>
{/* Created Date */}
<div className="text-xs text-muted-foreground pt-2 border-t">
Created{" "}
{format(
new Date(plan.createdAt),
"MMM dd, yyyy 'at' h:mm a"
)}
</div>
{/* Actions */}
<div className="flex gap-2 pt-2">
<Link href={`/plan/${plan.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
<Eye className="w-4 h-4 mr-2" />
View Details
</Button>
</Link>
<Button
variant="destructive"
size="sm"
className="flex-1"
onClick={() => handleDeletePlan(plan.id)}
disabled={deletingPlanId === plan.id}
>
{deletingPlanId === plan.id ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
Delete
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View file

@ -0,0 +1,26 @@
import { Heart } from "lucide-react";
export default function Footer() {
return (
<footer className="bg-card mt-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="border-t border-border pt-8 space-y-4">
<p className="text-center text-muted-foreground text-sm">
© 2024 TripCraft AI. All rights reserved.
</p>
<div className="flex items-center justify-center gap-1 text-sm text-muted-foreground">
Made with <Heart className="w-4 h-4 text-red-500 fill-current" /> by{" "}
<a
href="https://x.com/mtwn105"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline font-medium"
>
Amit Wani
</a>
</div>
</div>
</div>
</footer>
);
}

View file

@ -0,0 +1,79 @@
"use client";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Luggage } from "lucide-react";
export default function Header() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
async function handleLogout() {
try {
await authClient.signOut();
toast.success("Logged out successfully");
router.push("/");
} catch (error) {
toast.error("Failed to log out");
console.error("Logout error:", error);
}
}
return (
<header className="bg-card shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex items-center gap-8">
<Link href="/" className="hover:opacity-80 transition-opacity">
<h1 className="text-2xl font-bold text-accent">TripCraft AI</h1>
</Link>
<nav className="flex items-center gap-6">
<Link
href="/plans"
className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<Luggage className="w-4 h-4" />
My Plans
</Link>
</nav>
</div>
<div className="flex items-center gap-4">
{isPending ? (
<div className="text-sm text-muted-foreground">Loading...</div>
) : session?.user ? (
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted-foreground">Welcome, </span>
<span className="font-medium">
{session.user.name || session.user.email}
</span>
</div>
<Button variant="outline" size="sm" onClick={handleLogout}>
Logout
</Button>
</div>
) : (
<div className="flex items-center gap-3">
<Link href="/auth">
<Button variant="outline" size="sm">
Sign In
</Button>
</Link>
<Link href="/plan">
<Button className="bg-primary hover:bg-primary/90">
Get Started
</Button>
</Link>
</div>
)}
</div>
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,75 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
caption: "flex justify-center pt-1 relative items-center w-full",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-x-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"size-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start:
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
day_range_end:
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("size-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("size-4", className)} {...props} />
),
}}
{...props}
/>
)
}
export { Calendar }

View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View file

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View file

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View file

@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View file

@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View file

@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View file

@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View file

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View file

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View file

@ -0,0 +1,12 @@
import { createAuthClient } from "better-auth/react"
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BASE_URL || "",
redirects: {
afterSignIn: "/plan",
afterSignOut: "/auth"
},
fetchOptions: {
credentials: "include"
}
})

View file

@ -0,0 +1,15 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { PrismaClient } from "@/lib/generated/prisma";
const prisma = new PrismaClient();
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
},
});

View file

@ -0,0 +1,13 @@
import { PrismaClient } from "@/lib/generated/prisma";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL
export async function middleware(request: NextRequest) {
console.log("Host: ", BASE_URL);
const url = `${BASE_URL}/api/auth/get-session`;
console.log("URL: ", url);
const response = await fetch(url, {
headers: {
cookie: request.headers.get("cookie") || "",
},
});
if (!response.ok) {
return NextResponse.redirect(new URL("/auth", request.url));
}
const session = await response.json();
if (!session) {
return NextResponse.redirect(new URL("/auth", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/plan",],
};

View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

View file

@ -0,0 +1,56 @@
{
"name": "client",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@prisma/client": "^6.8.2",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"better-auth": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.511.0",
"next": "15.3.3",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.4",
"react-markdown": "^10.1.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.4",
"tailwind-merge": "^3.3.0",
"zod": "^3.25.46"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"prisma": "^6.8.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.2",
"typescript": "^5"
},
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c"
}

View file

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View file

@ -0,0 +1,79 @@
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3),
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "jwks" (
"id" TEXT NOT NULL,
"publicKey" TEXT NOT NULL,
"privateKey" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "jwks_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,36 @@
-- CreateTable
CREATE TABLE "trip_plan" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"destination" TEXT NOT NULL,
"startingLocation" TEXT NOT NULL,
"travelDatesStart" TEXT NOT NULL,
"travelDatesEnd" TEXT,
"dateInputType" TEXT NOT NULL DEFAULT 'picker',
"duration" INTEGER,
"travelingWith" TEXT NOT NULL,
"adults" INTEGER NOT NULL DEFAULT 1,
"children" INTEGER NOT NULL DEFAULT 0,
"ageGroups" TEXT[],
"budget" DOUBLE PRECISION NOT NULL,
"budgetCurrency" TEXT NOT NULL DEFAULT 'USD',
"travelStyle" TEXT NOT NULL,
"budgetFlexible" BOOLEAN NOT NULL DEFAULT false,
"vibes" TEXT[],
"priorities" TEXT[],
"interests" TEXT,
"rooms" INTEGER NOT NULL DEFAULT 1,
"pace" INTEGER[],
"planningStyle" TEXT,
"beenThereBefore" TEXT,
"lovedPlaces" TEXT,
"additionalInfo" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT,
CONSTRAINT "trip_plan_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "trip_plan" ADD CONSTRAINT "trip_plan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `planningStyle` on the `trip_plan` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "trip_plan" DROP COLUMN "planningStyle";

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View file

@ -0,0 +1,162 @@
generator client {
provider = "prisma-client-js"
output = "../lib/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id
name String
email String @unique
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
accounts Account[]
sessions Session[]
tripPlans TripPlan[]
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String @unique
createdAt DateTime
updatedAt DateTime
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}
model Jwks {
id String @id
publicKey String
privateKey String
createdAt DateTime
@@map("jwks")
}
model TripPlanStatus {
id String @id @default(cuid())
tripPlanId String @unique
status String @default("pending") // pending, processing, completed, failed
currentStep String?
error String?
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tripPlan TripPlan @relation(fields: [tripPlanId], references: [id], onDelete: Cascade)
@@map("trip_plan_status")
}
model TripPlanOutput {
id String @id @default(cuid())
tripPlanId String @unique
tripPlan TripPlan @relation(fields: [tripPlanId], references: [id], onDelete: Cascade)
itinerary String
summary String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("trip_plan_output")
}
model TripPlan {
id String @id @default(cuid())
name String
destination String
startingLocation String
travelDatesStart String
travelDatesEnd String?
dateInputType String @default("picker")
duration Int?
travelingWith String
adults Int @default(1)
children Int @default(0)
ageGroups String[]
budget Float
budgetCurrency String @default("USD")
travelStyle String
budgetFlexible Boolean @default(false)
vibes String[]
priorities String[]
interests String?
rooms Int @default(1)
pace Int[]
beenThereBefore String?
lovedPlaces String?
additionalInfo String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
status TripPlanStatus?
output TripPlanOutput?
@@map("trip_plan")
}
model plan_tasks {
id Int @id @default(autoincrement())
trip_plan_id String
task_type String
status plan_task_status
input_data Json
output_data Json?
error_message String?
created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_at DateTime? @default(now()) @db.Timestamptz(6)
@@index([status], map: "idx_plan_tasks_status")
@@index([trip_plan_id], map: "idx_plan_tasks_trip_plan_id")
}
enum plan_task_status {
queued
in_progress
success
error
}

View file

@ -0,0 +1,30 @@
-- Create trip_plan_status table
CREATE TABLE "trip_plan_status" (
"id" TEXT NOT NULL,
"tripPlanId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"currentStep" TEXT,
"error" TEXT,
"startedAt" TIMESTAMP,
"completedAt" TIMESTAMP,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP NOT NULL,
CONSTRAINT "trip_plan_status_pkey" PRIMARY KEY ("id"),
CONSTRAINT "trip_plan_status_tripPlanId_key" UNIQUE ("tripPlanId"),
CONSTRAINT "trip_plan_status_tripPlanId_fkey" FOREIGN KEY ("tripPlanId") REFERENCES "trip_plan"("id") ON DELETE CASCADE
);
-- Create trip_plan_output table
CREATE TABLE "trip_plan_output" (
"id" TEXT NOT NULL,
"tripPlanId" TEXT NOT NULL,
"itinerary" TEXT NOT NULL,
"summary" TEXT,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP NOT NULL,
CONSTRAINT "trip_plan_output_pkey" PRIMARY KEY ("id"),
CONSTRAINT "trip_plan_output_tripPlanId_key" UNIQUE ("tripPlanId"),
CONSTRAINT "trip_plan_output_tripPlanId_fkey" FOREIGN KEY ("tripPlanId") REFERENCES "trip_plan"("id") ON DELETE CASCADE
);
-- Create indexes for better query performance
CREATE INDEX "idx_trip_plan_status_tripPlanId" ON "trip_plan_status"("tripPlanId");
CREATE INDEX "idx_trip_plan_output_tripPlanId" ON "trip_plan_output"("tripPlanId");

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}