Spaces:
Sleeping
Sleeping
| 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"]) |