PlanMate / app.py
embed786's picture
Update app.py
7e4cf1d verified
import streamlit as st
from datetime import date
from streamlit.components.v1 import html as st_html
from planmate import utils
from planmate.config import APP_TITLE, APP_TAGLINE, THEME, CURRENCY
from planmate.flights import search_flights
from planmate.weather import summarize_forecast_for_range, geocode_city
from planmate.attractions import get_attractions_and_stays
from planmate.llm import rank_accommodations, resolve_city_to_iata_ai
from planmate.itinerary import build_itinerary
st.set_page_config(page_title=APP_TITLE, page_icon="✈️", layout="wide")
# ---------- Modern Green Theme Styles ----------
st.markdown(
"""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
/* Global Styles */
html, body, [class*="css"] {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.main .block-container {
padding-top: 2rem;
padding-bottom: 2rem;
max-width: 1200px;
}
/* Color Variables */
:root {
--primary-green: #10B981;
--primary-green-dark: #059669;
--primary-green-light: #6EE7B7;
--accent-green: #D1FAE5;
--dark-green: #064E3B;
--text-primary: #1F2937;
--text-secondary: #6B7280;
--background: #F9FAFB;
--card-background: #FFFFFF;
--border: #E5E7EB;
--border-light: #F3F4F6;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* Main Background */
.main {
background: linear-gradient(135deg, #ECFDF5 0%, #F0FDF4 100%);
}
/* Header Styling */
.main h1 {
color: var(--dark-green);
font-weight: 700;
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, var(--primary-green), var(--primary-green-dark));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.main h2 {
color: var(--dark-green);
font-weight: 600;
font-size: 1.5rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.main h3 {
color: var(--text-primary);
font-weight: 600;
font-size: 1.25rem;
margin-bottom: 0.75rem;
}
/* Card Styling */
.modern-card {
background: var(--card-background);
border-radius: 16px;
padding: 1.5rem;
margin-bottom: 1rem;
box-shadow: var(--shadow);
border: 1px solid var(--border-light);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.modern-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--primary-green), var(--primary-green-light));
}
.modern-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* Success Banner */
.success-banner {
background: linear-gradient(135deg, var(--accent-green), var(--primary-green-light));
border: 1px solid var(--primary-green-light);
border-radius: 12px;
padding: 1rem 1.5rem;
margin: 1rem 0;
color: var(--dark-green);
font-weight: 500;
display: flex;
align-items: center;
gap: 0.75rem;
}
.success-banner::before {
content: 'βœ…';
font-size: 1.2rem;
}
/* Info Box */
.info-box {
background: linear-gradient(135deg, #EBF8FF, #DBEAFE);
border: 1px solid #93C5FD;
border-radius: 12px;
padding: 1rem 1.5rem;
margin: 1rem 0;
color: #1E40AF;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.75rem;
}
.info-box::before {
content: 'ℹ️';
font-size: 1.2rem;
}
/* Warning Box */
.warning-box {
background: linear-gradient(135deg, #FFFBEB, #FEF3C7);
border: 1px solid #FCD34D;
border-radius: 12px;
padding: 1rem 1.5rem;
margin: 1rem 0;
color: #92400E;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.75rem;
}
.warning-box::before {
content: '⚠️';
font-size: 1.2rem;
}
/* Sidebar Styling */
.css-1d391kg, .css-1y4p8pa {
background: linear-gradient(180deg, var(--card-background) 0%, #F8FAFC 100%);
border-right: 1px solid var(--border);
}
/* Button Styling */
.stButton > button {
background: linear-gradient(135deg, var(--primary-green), var(--primary-green-dark));
color: white;
border: none;
border-radius: 10px;
padding: 0.75rem 1.5rem;
font-weight: 600;
font-size: 0.95rem;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(16, 185, 129, 0.2);
min-height: 3rem;
}
.stButton > button:hover {
background: linear-gradient(135deg, var(--primary-green-dark), #047857);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(16, 185, 129, 0.3);
}
.stButton > button:active {
transform: translateY(0);
}
/* Secondary Button */
.secondary-button {
background: transparent !important;
color: var(--primary-green) !important;
border: 2px solid var(--primary-green) !important;
}
.secondary-button:hover {
background: var(--accent-green) !important;
color: var(--dark-green) !important;
}
/* Input Styling */
.stTextInput > div > div > input,
.stNumberInput > div > div > input,
.stSelectbox > div > div > select,
.stDateInput > div > div > input {
border-radius: 10px;
border: 2px solid var(--border);
padding: 0.75rem;
font-size: 0.95rem;
transition: all 0.3s ease;
background: var(--card-background);
}
.stTextInput > div > div > input:focus,
.stNumberInput > div > div > input:focus,
.stSelectbox > div > div > select:focus,
.stDateInput > div > div > input:focus {
border-color: var(--primary-green);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
outline: none;
}
/* Checkbox Styling */
.stCheckbox > label {
font-weight: 500;
color: var(--text-primary);
}
/* Flight Card Specific */
.flight-card {
background: var(--card-background);
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1rem;
border: 2px solid var(--border-light);
transition: all 0.3s ease;
position: relative;
}
.flight-card:hover {
border-color: var(--primary-green-light);
transform: translateY(-1px);
box-shadow: var(--shadow);
}
.flight-selected {
border-color: var(--primary-green);
background: linear-gradient(135deg, var(--accent-green), #F0FDF4);
}
/* Weather Item */
.weather-item {
background: var(--card-background);
border-radius: 10px;
padding: 1rem;
margin: 0.5rem 0;
border-left: 4px solid var(--primary-green);
box-shadow: var(--shadow);
}
/* Attraction Item */
.attraction-item {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background: var(--card-background);
border-radius: 10px;
margin: 0.5rem 0;
border: 1px solid var(--border);
transition: all 0.3s ease;
}
.attraction-item:hover {
border-color: var(--primary-green-light);
transform: translateX(4px);
}
/* Section Divider */
.section-divider {
height: 2px;
background: linear-gradient(90deg, transparent, var(--primary-green-light), transparent);
margin: 2rem 0;
border: none;
}
/* Progress Bar */
.progress-container {
background: var(--border-light);
border-radius: 10px;
height: 6px;
margin: 1rem 0;
overflow: hidden;
}
.progress-bar {
background: linear-gradient(90deg, var(--primary-green), var(--primary-green-light));
height: 100%;
border-radius: 10px;
transition: width 0.3s ease;
}
/* Responsive Design */
@media (max-width: 768px) {
.main .block-container {
padding-left: 1rem;
padding-right: 1rem;
}
.main h1 {
font-size: 2rem;
}
.modern-card {
padding: 1rem;
}
}
/* Animation for loading states */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.loading {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Caption styling */
.main .css-1v0mbdj p {
color: var(--text-secondary);
font-size: 1.1rem;
margin-top: 0.5rem;
}
/* Sidebar header */
.css-1d391kg h2,
.css-1y4p8pa h2 {
color: var(--dark-green);
font-weight: 600;
}
/* Expander styling */
.streamlit-expanderHeader {
background: var(--accent-green);
border-radius: 8px;
color: var(--dark-green);
font-weight: 500;
}
/* Tab styling */
.stTabs [data-baseweb="tab-list"] {
gap: 0.5rem;
}
.stTabs [data-baseweb="tab"] {
background: var(--card-background);
border-radius: 8px;
border: 2px solid var(--border);
color: var(--text-secondary);
font-weight: 500;
}
.stTabs [data-baseweb="tab"]:hover {
border-color: var(--primary-green-light);
}
.stTabs [aria-selected="true"] {
background: var(--primary-green);
border-color: var(--primary-green);
color: white;
}
</style>
""",
unsafe_allow_html=True,
)
# ---------- Header ----------
colH1, colH2 = st.columns([0.8, 0.2])
with colH1:
# Option 1: Replace with logo image
try:
st.image("planmate-logo.png", width=250) # Adjust width as needed
st.caption(APP_TAGLINE)
except:
# Fallback if logo not found
st.title(APP_TITLE)
st.caption(APP_TAGLINE)
# Option 2: Logo with text combination (uncomment to use instead)
# col_logo, col_text = st.columns([0.3, 0.7])
# with col_logo:
# try:
# st.image("logo.png", width=80)
# except:
# st.write("πŸš€") # Fallback emoji
# with col_text:
# st.title(APP_TITLE)
# st.caption(APP_TAGLINE)
with colH2:
if st.button("πŸ”„ Start Over", use_container_width=True, help="Reset and start planning a new trip"):
for k in list(st.session_state.keys()):
del st.session_state[k]
st.rerun()
# ---------- Sidebar Inputs ----------
with st.sidebar:
st.markdown("### 🧳 Trip Planning")
with st.container():
src_city = st.text_input("πŸ›« From", value="Lahore", help="Enter your departure city")
dst_city = st.text_input("πŸ›¬ To", value="Dubai", help="Enter your destination city")
col1, col2 = st.columns(2)
with col1:
start_date = st.date_input("πŸ“… Start Date", value=date.today())
with col2:
num_days = st.number_input("πŸ“† Days", min_value=1, max_value=30, value=5, step=1)
col3, col4 = st.columns(2)
with col3:
adults = st.number_input("πŸ‘₯ Adults", min_value=1, max_value=9, value=1, step=1)
with col4:
non_stop = st.checkbox("✈️ Non-stop", value=False, help="Direct flights only")
with st.expander("βš™οΈ Advanced Options", expanded=False):
st.markdown("*Override AI-suggested airport codes*")
override_origin = st.text_input("Origin IATA Code", placeholder="e.g., LHE", help="Optional: Force specific origin airport")
override_dest = st.text_input("Destination IATA Code", placeholder="e.g., DXB", help="Optional: Force specific destination airport")
st.markdown("---")
go = st.button("πŸš€ Plan My Trip", type="primary", use_container_width=True)
# ---------- Helpers ----------
def set_planned(**kwargs):
"""Persist a 'planned' flag and any key/value to session."""
st.session_state.planned = True
for k, v in kwargs.items():
st.session_state[k] = v
def not_planned():
return not st.session_state.get("planned", False)
def scroll_to(target_id: str):
"""Smooth-scroll to a section with the given DOM id."""
st_html(
f"""
<script>
setTimeout(function() {{
const el = parent.document.getElementById("{target_id}");
if (el) el.scrollIntoView({{ behavior: "smooth", block: "start" }});
}}, 120);
</script>
""",
height=0,
)
# flight panel visibility toggle
if "show_flights" not in st.session_state:
st.session_state.show_flights = False
# ---------- First click: plan & fetch flights ----------
if go:
# Country hints (optional) to improve AI IATA mapping
src_country = None
dst_country = None
try:
src_country = geocode_city(src_city).get("country")
except Exception:
pass
try:
dst_country = geocode_city(dst_city).get("country")
except Exception:
pass
# Resolve to IATA via AI, with optional overrides
try:
if override_origin.strip():
origin_code, origin_label, origin_kind = (
override_origin.strip().upper(),
f"(override) {override_origin.strip().upper()}",
"OVERRIDE",
)
else:
code, name, kind = resolve_city_to_iata_ai(src_city, src_country)
origin_code, origin_label, origin_kind = code, f"{name} ({code})", kind
if override_dest.strip():
dest_code, dest_label, dest_kind = (
override_dest.strip().upper(),
f"(override) {override_dest.strip().upper()}",
"OVERRIDE",
)
else:
code, name, kind = resolve_city_to_iata_ai(dst_city, dst_country)
dest_code, dest_label, dest_kind = code, f"{name} ({code})", kind
except Exception as e:
st.markdown(
f"""
<div class="warning-box">
Either city was not found or it does not have an airport: {str(e)}
</div>
""",
unsafe_allow_html=True
)
st.stop()
# Compute return date
ret_date = utils.compute_return_date(start_date, int(num_days))
# Fetch flights once and store
try:
with st.spinner("πŸ” Searching for the best flights..."):
flights = search_flights(
origin_code,
dest_code,
utils.to_iso(start_date),
utils.to_iso(ret_date),
int(adults),
CURRENCY,
non_stop=non_stop,
)
except Exception as e:
st.markdown(
f"""
<div class="warning-box">
Flight search failed: {str(e)}
</div>
""",
unsafe_allow_html=True
)
st.stop()
set_planned(
src_city=src_city,
dst_city=dst_city,
start_date=start_date,
num_days=int(num_days),
adults=int(adults),
non_stop=bool(non_stop),
origin_code=origin_code,
origin_label=origin_label,
origin_kind=origin_kind,
dest_code=dest_code,
dest_label=dest_label,
dest_kind=dest_kind,
ret_date=ret_date,
flights=flights,
flight_idx=None,
show_flights=True,
weather=None,
pois=None,
selected_stay=None,
itinerary_md=None,
scroll_target=None,
)
# ---------- Render results if we have a planned trip ----------
if not_planned():
st.markdown(
"""
<div class="info-box">
Fill in your trip details in the sidebar and click <strong>Plan My Trip</strong> to get started with your adventure!
</div>
""",
unsafe_allow_html=True
)
st.stop()
# Banner for resolved IATA
st.markdown(
f"""
<div class="success-banner">
<strong>Routes Resolved:</strong> {st.session_state.src_city} β†’ {st.session_state.origin_label} [{st.session_state.origin_kind}] β€’
{st.session_state.dst_city} β†’ {st.session_state.dest_label} [{st.session_state.dest_kind}]
</div>
""",
unsafe_allow_html=True
)
# ---------- Flights ----------
st.markdown('<hr class="section-divider">', unsafe_allow_html=True)
st.markdown('<div id="section-flights"></div>', unsafe_allow_html=True)
st.subheader("✈️ Flight Options")
fl = st.session_state.get("flights", {"flights": []})
# If user has selected a flight, show a compact summary
if st.session_state.get("flight_idx") is not None:
sel_offer = fl["flights"][st.session_state.flight_idx]
st.markdown(
f"""
<div class="modern-card flight-selected">
<h4>🎯 Selected Flight β€” {sel_offer['price_label']}</h4>
</div>
""",
unsafe_allow_html=True
)
for leg_i, segs in enumerate(sel_offer["legs"]):
st.write(f"**✈️ Leg {leg_i+1}**")
for s in segs:
st.write(f"β€’ {s['from']} β†’ {s['to']} | {s['dep']} β†’ {s['arr']} | {s['carrier']} {s['number']}")
if st.button("πŸ”„ Change Flight", help="Select a different flight option"):
st.session_state.flight_idx = None
st.session_state.show_flights = True
st.session_state.weather = None
st.session_state.pois = None
st.session_state.itinerary_md = None
st.session_state.scroll_target = "section-flights"
st.rerun()
else:
if not fl.get("flights"):
st.markdown(
"""
<div class="warning-box">
No flight offers found for the selected dates/route. Try different dates or cities.
</div>
""",
unsafe_allow_html=True
)
else:
if st.session_state.get("show_flights", True):
for idx, offer in enumerate(fl["flights"][:10]):
st.markdown(
f"""
<div class="flight-card">
<h4>πŸ›« Option {idx+1} β€” {offer['price_label']}</h4>
""",
unsafe_allow_html=True
)
for leg_i, segs in enumerate(offer["legs"]):
st.write(f"**Leg {leg_i+1}**")
for s in segs:
st.write(f"β€’ {s['from']} β†’ {s['to']} | {s['dep']} β†’ {s['arr']} | {s['carrier']} {s['number']}")
if st.button("βœ… Select This Flight", key=f"select-flight-{idx}"):
st.session_state.flight_idx = idx
st.session_state.show_flights = False
st.session_state.weather = None
st.session_state.pois = None
st.session_state.itinerary_md = None
st.session_state.scroll_target = "section-weather"
st.rerun()
st.markdown("</div>", unsafe_allow_html=True)
else:
if st.button("πŸ‘€ Show Flight Options"):
st.session_state.show_flights = True
st.rerun()
# Gate further steps until a flight is selected
if st.session_state.get("flight_idx") is None:
st.markdown(
"""
<div class="info-box">
Select a flight to proceed to weather, attractions, and itinerary planning.
</div>
""",
unsafe_allow_html=True
)
if st.session_state.get("scroll_target"):
scroll_to(st.session_state["scroll_target"])
st.session_state["scroll_target"] = None
st.stop()
# ---------- Weather ----------
st.markdown('<hr class="section-divider">', unsafe_allow_html=True)
st.markdown('<div id="section-weather"></div>', unsafe_allow_html=True)
st.subheader("🌦️ Weather Forecast")
if st.session_state.weather is None:
try:
with st.spinner("🌀️ Fetching weather forecast..."):
st.session_state.weather = summarize_forecast_for_range(
st.session_state.dst_city, st.session_state.start_date, st.session_state.num_days
)
except Exception as e:
st.markdown(
f"""
<div class="warning-box">
Weather data unavailable: {str(e)}
</div>
""",
unsafe_allow_html=True
)
st.session_state.weather = {"summary_text": "Weather data not available", "daily": []}
weather = st.session_state.weather
if "location" in weather:
st.markdown(
f"""
<div class="success-banner">
πŸ“ {weather['location'].get('name')}, {weather['location'].get('country')}
</div>
""",
unsafe_allow_html=True
)
for r in weather.get("daily", []):
st.markdown(
f"""
<div class="weather-item">
<strong>{r['date']}</strong>: {r['desc']} β€’ {r['temp_avg']}Β°C β€’ Wind {r['wind']} m/s
</div>
""",
unsafe_allow_html=True
)
# ---------- Attractions & Stays ----------
st.markdown('<hr class="section-divider">', unsafe_allow_html=True)
st.markdown('<div id="section-attractions"></div>', unsafe_allow_html=True)
st.subheader("πŸ“ Attractions & Accommodations")
if st.session_state.pois is None:
try:
with st.spinner("πŸ—ΊοΈ Discovering attractions and accommodations..."):
loc = geocode_city(st.session_state.dst_city)
st.session_state.pois = get_attractions_and_stays(lat=loc["lat"], lon=loc["lon"], radius=8000)
except Exception as e:
st.markdown(
f"""
<div class="warning-box">
Attractions lookup failed: {str(e)}
</div>
""",
unsafe_allow_html=True
)
st.session_state.pois = {"attractions": [], "stays": []}
pois = st.session_state.pois
attractions = pois.get("attractions", [])[:30]
stays = pois.get("stays", [])[:30]
# Select attractions
st.markdown("#### 🎯 **Top Attractions**")
selected_attractions = []
for i, a in enumerate(attractions[:12]):
col1, col2 = st.columns([0.85, 0.15])
with col1:
st.markdown(
f"""
<div class="attraction-item">
πŸ“Œ <strong>{a['name']}</strong> <em>({a.get('kinds','')})</em>
</div>
""",
unsafe_allow_html=True
)
with col2:
add = st.checkbox("Add", key=f"attr-{i}", help=f"Include {a['name']} in your itinerary")
if add:
selected_attractions.append(a)
if not selected_attractions:
st.markdown(
"""
<div class="info-box">
πŸ’‘ Select attractions you'd like to visit for a personalized itinerary.
</div>
""",
unsafe_allow_html=True
)
# Stays with LLM ranking
st.markdown("#### 🏨 **Accommodations**")
st.caption("*Powered by OpenTripMap - for reference only, no live booking*")
try:
ranked_stays = rank_accommodations(stays, prefs="central, well-reviewed, convenient")
except Exception:
ranked_stays = stays
stay_names = [s["name"] for s in ranked_stays[:15]]
chosen_stay_name = st.selectbox(
"Choose accommodation",
options=["(None selected)"] + stay_names,
help="Select a place to stay for your trip"
)
selected_stay = None if chosen_stay_name == "(None selected)" else next(
(s for s in ranked_stays if s["name"] == chosen_stay_name), None
)
st.session_state.selected_stay = selected_stay
# ---------- Review & Booking (Mock) ----------
st.markdown('<hr class="section-divider">', unsafe_allow_html=True)
st.markdown('<div id="section-booking"></div>', unsafe_allow_html=True)
st.subheader("🎫 Review & Booking")
st.markdown("#### **Flight Summary**")
sel_offer = fl["flights"][st.session_state.flight_idx]
st.markdown(
f"""
<div class="modern-card">
<h4>πŸ’° Total Cost: {sel_offer['price_label']}</h4>
<p><em>⚠️ This is a demo environment - no actual booking or payment processed</em></p>
</div>
""",
unsafe_allow_html=True
)
col1, col2 = st.columns(2)
with col1:
passenger_name = st.text_input("πŸ‘€ Full Name", value="Test User", help="Passenger name for booking")
with col2:
passenger_email = st.text_input("πŸ“§ Email", value="test@example.com", help="Contact email")
if st.button("🎫 Reserve Flight (Demo)", help="Simulate flight booking"):
import uuid
pnr = str(uuid.uuid4())[:8].upper()
st.markdown(
f"""
<div class="success-banner">
πŸŽ‰ Flight reserved! Reference: <strong>{pnr}</strong>
</div>
""",
unsafe_allow_html=True
)
st.session_state.flight_pnr = pnr
if st.session_state.selected_stay:
st.markdown(f"#### **Hotel Selected:** {st.session_state.selected_stay['name']}")
if st.button("🏨 Reserve Hotel (Demo)", help="Simulate hotel booking"):
import uuid
hid = str(uuid.uuid4())[:10].upper()
st.markdown(
f"""
<div class="success-banner">
🏨 Hotel reserved! Reference: <strong>{hid}</strong>
</div>
""",
unsafe_allow_html=True
)
st.session_state.hotel_ref = hid
# ---------- Itinerary ----------
st.markdown('<hr class="section-divider">', unsafe_allow_html=True)
st.markdown('<div id="section-itinerary"></div>', unsafe_allow_html=True)
st.subheader("πŸ—“οΈ AI Itinerary")
if st.button("πŸ€– Generate Itinerary", help="Create a personalized day-by-day itinerary using AI"):
with st.spinner("🧠 AI planning your perfect itinerary..."):
try:
st.session_state.itinerary_md = build_itinerary(
st.session_state.dst_city,
utils.to_iso(st.session_state.start_date),
int(st.session_state.num_days),
selected_attractions,
st.session_state.selected_stay,
weather.get("summary_text", ""),
)
# Optionally scroll to itinerary when generated
st.session_state.scroll_target = "section-itinerary"
except Exception as e:
st.markdown(
f"""
<div class="warning-box">
Itinerary generation failed: {str(e)}
</div>
""",
unsafe_allow_html=True
)
if st.session_state.get("itinerary_md"):
st.markdown(
"""
<div class="modern-card">
<h4>πŸ“‹ Your Personalized Itinerary</h4>
</div>
""",
unsafe_allow_html=True
)
st.markdown(st.session_state.itinerary_md)
# ---------- Final: perform any pending scroll ----------
if st.session_state.get("scroll_target"):
scroll_to(st.session_state["scroll_target"])