embed786 commited on
Commit
086a284
Β·
verified Β·
1 Parent(s): bcafc09

Upload 4 files

Browse files
Files changed (4) hide show
  1. README.md +36 -13
  2. app.py +1303 -0
  3. planmate-logo.png +0 -0
  4. requirements.txt +5 -0
README.md CHANGED
@@ -1,13 +1,36 @@
1
- ---
2
- title: PlanMate
3
- emoji: πŸ”₯
4
- colorFrom: gray
5
- colorTo: pink
6
- sdk: streamlit
7
- sdk_version: 1.49.1
8
- app_file: app/main.py
9
- pinned: false
10
- short_description: PlanMate - AI Power smart trip planner
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PlanMate β€” AI Power smart trip planner
2
+
3
+ A modern Streamlit app to plan trips:
4
+ - Search flights (Amadeus **Sandbox**)
5
+ - View weather (OpenWeather)
6
+ - Discover attractions & places to stay (OpenTripMap)
7
+ - Generate a day-by-day itinerary (Google Gemini)
8
+ - Simulated bookings for flights & hotels (no payments)
9
+
10
+ ## Tech
11
+ - Python, Streamlit UI
12
+ - google-generativeai (Gemini)
13
+ - Amadeus Sandbox
14
+ - OpenWeather
15
+ - OpenTripMap
16
+
17
+ ## Environment
18
+ Set the following environment variables (e.g., create `.env` for local dev). The app auto-loads `.env` if present:
19
+ ```
20
+ GEMINI_API_KEY=...
21
+ AMADEUS_CLIENT_ID=...
22
+ AMADEUS_CLIENT_SECRET=...
23
+ OPENWEATHER_API_KEY=...
24
+ OPENTRIPMAP_API_KEY=...
25
+ ```
26
+
27
+ ## Run locally
28
+ ```
29
+ pip install -r requirements.txt
30
+ streamlit run app.py
31
+ ```
32
+
33
+ ## Notes
34
+ - Flights & hotels β€œbooking” are simulated in MVP (no real payment).
35
+ - Flight search uses Amadeus Sandbox; results vary and may require adjusting dates/airports.
36
+ - Currency is PKR; units are metric; language English.
app.py ADDED
@@ -0,0 +1,1303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # import streamlit as st
2
+ # from datetime import date
3
+ # from streamlit.components.v1 import html as st_html
4
+
5
+ # from planmate import utils
6
+ # from planmate.config import APP_TITLE, APP_TAGLINE, THEME, CURRENCY
7
+ # from planmate.flights import search_flights
8
+ # from planmate.weather import summarize_forecast_for_range, geocode_city
9
+ # from planmate.attractions import get_attractions_and_stays
10
+ # from planmate.llm import rank_accommodations, resolve_city_to_iata_ai
11
+ # from planmate.itinerary import build_itinerary
12
+
13
+ # st.set_page_config(page_title=APP_TITLE, page_icon="✈️", layout="wide")
14
+
15
+ # # ---------- Styles ----------
16
+ # st.markdown(
17
+ # f"""
18
+ # <style>
19
+ # html, body, [class*="block-container"] {{
20
+ # color: {THEME['text']};
21
+ # background-color: {THEME['bg']};
22
+ # }}
23
+ # .label {{
24
+ # color: {THEME['label']};
25
+ # font-weight: 600;
26
+ # }}
27
+ # .card {{
28
+ # border: 1px solid {THEME['border']};
29
+ # border-radius: 10px;
30
+ # padding: 12px;
31
+ # margin-bottom: 10px;
32
+ # background: #ffffff;
33
+ # }}
34
+ # .btn-primary {{
35
+ # background: {THEME['label']};
36
+ # color: white;
37
+ # border: none;
38
+ # padding: 8px 12px;
39
+ # border-radius: 8px;
40
+ # }}
41
+ # hr {{
42
+ # border-top: 1px solid {THEME['border']};
43
+ # opacity: 0.2;
44
+ # }}
45
+ # </style>
46
+ # """,
47
+ # unsafe_allow_html=True,
48
+ # )
49
+
50
+ # # ---------- Header ----------
51
+ # colH1, colH2 = st.columns([0.8, 0.2])
52
+ # with colH1:
53
+ # st.title(APP_TITLE)
54
+ # st.caption(APP_TAGLINE)
55
+ # with colH2:
56
+ # if st.button("πŸ” Start over", use_container_width=True):
57
+ # for k in list(st.session_state.keys()):
58
+ # del st.session_state[k]
59
+ # st.rerun()
60
+
61
+ # # ---------- Sidebar Inputs ----------
62
+ # with st.sidebar:
63
+ # st.subheader("Trip Basics")
64
+ # src_city = st.text_input("Source City", value="Lahore")
65
+ # dst_city = st.text_input("Destination City", value="Dubai")
66
+ # start_date = st.date_input("Start Date", value=date.today())
67
+ # num_days = st.number_input("Number of Days", min_value=1, max_value=30, value=5, step=1)
68
+ # adults = st.number_input("Adults", min_value=1, max_value=9, value=1, step=1)
69
+ # non_stop = st.checkbox("Non-stop only", value=False)
70
+
71
+ # advanced = st.expander("Advanced (optional): Override IATA codes returned by AI")
72
+ # with advanced:
73
+ # override_origin = st.text_input("Origin IATA (optional)")
74
+ # override_dest = st.text_input("Destination IATA (optional)")
75
+
76
+ # go = st.button("Plan my trip", type="primary")
77
+
78
+
79
+ # # ---------- Helpers ----------
80
+ # def set_planned(**kwargs):
81
+ # """Persist a 'planned' flag and any key/value to session."""
82
+ # st.session_state.planned = True
83
+ # for k, v in kwargs.items():
84
+ # st.session_state[k] = v
85
+
86
+
87
+ # def not_planned():
88
+ # return not st.session_state.get("planned", False)
89
+
90
+
91
+ # def scroll_to(target_id: str):
92
+ # """
93
+ # Smooth-scroll to a section with the given DOM id.
94
+ # Uses a short delay so the element exists in the DOM on rerun.
95
+ # """
96
+ # st_html(
97
+ # f"""
98
+ # <script>
99
+ # setTimeout(function() {{
100
+ # const el = parent.document.getElementById("{target_id}");
101
+ # if (el) el.scrollIntoView({{ behavior: "smooth", block: "start" }});
102
+ # }}, 120);
103
+ # </script>
104
+ # """,
105
+ # height=0,
106
+ # )
107
+
108
+
109
+ # # flight panel visibility toggle
110
+ # if "show_flights" not in st.session_state:
111
+ # st.session_state.show_flights = False
112
+
113
+
114
+ # # ---------- First click: plan & fetch flights ----------
115
+ # if go:
116
+ # # Country hints (optional) to improve AI IATA mapping
117
+ # src_country = None
118
+ # dst_country = None
119
+ # try:
120
+ # src_country = geocode_city(src_city).get("country")
121
+ # except Exception:
122
+ # pass
123
+ # try:
124
+ # dst_country = geocode_city(dst_city).get("country")
125
+ # except Exception:
126
+ # pass
127
+
128
+ # # Resolve to IATA via AI, with optional overrides
129
+ # try:
130
+ # if override_origin.strip():
131
+ # origin_code, origin_label, origin_kind = (
132
+ # override_origin.strip().upper(),
133
+ # f"(override) {override_origin.strip().upper()}",
134
+ # "OVERRIDE",
135
+ # )
136
+ # else:
137
+ # code, name, kind = resolve_city_to_iata_ai(src_city, src_country)
138
+ # origin_code, origin_label, origin_kind = code, f"{name} ({code})", kind
139
+
140
+ # #if code == "":
141
+ # # st.error(f"AI could not find a valid IATA code for the source city: {src_city}")
142
+ # # pass
143
+ # if override_dest.strip():
144
+ # dest_code, dest_label, dest_kind = (
145
+ # override_dest.strip().upper(),
146
+ # f"(override) {override_dest.strip().upper()}",
147
+ # "OVERRIDE",
148
+ # )
149
+ # else:
150
+ # code, name, kind = resolve_city_to_iata_ai(dst_city, dst_country)
151
+ # dest_code, dest_label, dest_kind = code, f"{name} ({code})", kind
152
+
153
+ # #if code == "":
154
+ # # st.error(f"AI could not find a valid IATA code for the destination city: {dst_city}")
155
+ # # pass
156
+ # # st.write(f"Resolved IATA codes: {dest_code} {dest_label} {dest_kind}, {origin_code} {origin_label} {origin_kind}")
157
+ # except Exception as e:
158
+ # st.error(f"Either city was not found or it does not have an airport")
159
+ # st.stop()
160
+
161
+ # # Compute return date
162
+ # ret_date = utils.compute_return_date(start_date, int(num_days))
163
+
164
+ # # Fetch flights once and store
165
+ # try:
166
+ # with st.spinner("Searching flights (Amadeus Sandbox)..."):
167
+ # flights = search_flights(
168
+ # origin_code,
169
+ # dest_code,
170
+ # utils.to_iso(start_date),
171
+ # utils.to_iso(ret_date),
172
+ # int(adults),
173
+ # CURRENCY,
174
+ # non_stop=non_stop,
175
+ # )
176
+ # except Exception as e:
177
+ # st.error(f"Flight search failed: {e}")
178
+ # st.stop()
179
+
180
+ # set_planned(
181
+ # src_city=src_city,
182
+ # dst_city=dst_city,
183
+ # start_date=start_date,
184
+ # num_days=int(num_days),
185
+ # adults=int(adults),
186
+ # non_stop=bool(non_stop),
187
+ # origin_code=origin_code,
188
+ # origin_label=origin_label,
189
+ # origin_kind=origin_kind,
190
+ # dest_code=dest_code,
191
+ # dest_label=dest_label,
192
+ # dest_kind=dest_kind,
193
+ # ret_date=ret_date,
194
+ # flights=flights,
195
+ # flight_idx=None, # not selected yet
196
+ # show_flights=True, # show list now
197
+ # weather=None,
198
+ # pois=None,
199
+ # selected_stay=None,
200
+ # itinerary_md=None,
201
+ # scroll_target=None,
202
+ # )
203
+
204
+ # # ---------- Render results if we have a planned trip ----------
205
+ # if not_planned():
206
+ # st.info("Fill the details in the sidebar and click **Plan my trip** to get started.")
207
+ # st.stop()
208
+
209
+ # # Banner for resolved IATA
210
+ # st.success(
211
+ # f"Resolved by AI: **{st.session_state.src_city} β†’ {st.session_state.origin_label} "
212
+ # f"[{st.session_state.origin_kind}]**, "
213
+ # f"**{st.session_state.dst_city} β†’ {st.session_state.dest_label} "
214
+ # f"[{st.session_state.dest_kind}]**"
215
+ # )
216
+
217
+ # # ---------- Flights ----------
218
+ # st.markdown("---")
219
+ # st.markdown('<div id="section-flights"></div>', unsafe_allow_html=True)
220
+ # st.subheader("✈️ Flight Options")
221
+
222
+ # fl = st.session_state.get("flights", {"flights": []})
223
+
224
+ # # If user has selected a flight, show a compact summary (panel hidden)
225
+ # if st.session_state.get("flight_idx") is not None:
226
+ # sel_offer = fl["flights"][st.session_state.flight_idx]
227
+ # with st.container():
228
+ # st.markdown('<div class="card">', unsafe_allow_html=True)
229
+ # st.write(f"**Selected Flight** β€” {sel_offer['price_label']}")
230
+ # for leg_i, segs in enumerate(sel_offer["legs"]):
231
+ # st.write(f"_Leg {leg_i+1}_")
232
+ # for s in segs:
233
+ # st.write(
234
+ # f"- {s['from']} β†’ {s['to']} | {s['dep']} β†’ {s['arr']} | {s['carrier']} {s['number']}"
235
+ # )
236
+ # # Allow changing selection (re-open list, clear downstream steps)
237
+ # if st.button("Change flight"):
238
+ # st.session_state.flight_idx = None
239
+ # st.session_state.show_flights = True
240
+ # st.session_state.weather = None
241
+ # st.session_state.pois = None
242
+ # st.session_state.itinerary_md = None
243
+ # # optional: scroll back to flights list
244
+ # st.session_state.scroll_target = "section-flights"
245
+ # st.rerun()
246
+ # st.markdown("</div>", unsafe_allow_html=True)
247
+
248
+ # else:
249
+ # if not fl.get("flights"):
250
+ # st.warning("No flight offers found for the selected dates/route. Try different dates or cities.")
251
+ # else:
252
+ # if st.session_state.get("show_flights", True):
253
+ # for idx, offer in enumerate(fl["flights"][:10]):
254
+ # with st.container():
255
+ # st.markdown('<div class="card">', unsafe_allow_html=True)
256
+ # st.write(f"**Option {idx+1} β€” {offer['price_label']}**")
257
+ # for leg_i, segs in enumerate(offer["legs"]):
258
+ # st.write(f"_Leg {leg_i+1}_")
259
+ # for s in segs:
260
+ # st.write(
261
+ # f"- {s['from']} β†’ {s['to']} | {s['dep']} β†’ {s['arr']} | {s['carrier']} {s['number']}"
262
+ # )
263
+ # if st.button("Select this flight", key=f"select-flight-{idx}"):
264
+ # # store selection, hide the panel, clear downstream (so they refresh for the chosen flight)
265
+ # st.session_state.flight_idx = idx
266
+ # st.session_state.show_flights = False
267
+ # st.session_state.weather = None
268
+ # st.session_state.pois = None
269
+ # st.session_state.itinerary_md = None
270
+
271
+ # # ➜ tell the next render to scroll to the Weather section
272
+ # st.session_state.scroll_target = "section-weather"
273
+
274
+ # st.rerun()
275
+ # st.markdown("</div>", unsafe_allow_html=True)
276
+ # else:
277
+ # # Safety net: if hidden but nothing is selected, show a reopen button
278
+ # if st.button("Show flight options"):
279
+ # st.session_state.show_flights = True
280
+ # st.rerun()
281
+
282
+ # # Gate further steps until a flight is selected
283
+ # if st.session_state.get("flight_idx") is None:
284
+ # st.info("Select a flight to proceed to weather, attractions, and itinerary.")
285
+ # # Perform any pending scroll (unlikely here, but safe)
286
+ # if st.session_state.get("scroll_target"):
287
+ # scroll_to(st.session_state["scroll_target"])
288
+ # st.session_state["scroll_target"] = None
289
+ # st.stop()
290
+
291
+ # # ---------- Weather ----------
292
+ # st.markdown("---")
293
+ # st.markdown('<div id="section-weather"></div>', unsafe_allow_html=True)
294
+ # st.subheader("🌦️ Weather During Your Dates")
295
+ # if st.session_state.weather is None:
296
+ # try:
297
+ # with st.spinner("Fetching weather..."):
298
+ # st.session_state.weather = summarize_forecast_for_range(
299
+ # st.session_state.dst_city, st.session_state.start_date, st.session_state.num_days
300
+ # )
301
+ # except Exception as e:
302
+ # st.warning(f"Weather unavailable: {e}")
303
+ # st.session_state.weather = {"summary_text": "Weather data not available", "daily": []}
304
+
305
+ # weather = st.session_state.weather
306
+ # if "location" in weather:
307
+ # st.success(f"Location: {weather['location'].get('name')}, {weather['location'].get('country')}")
308
+ # for r in weather.get("daily", []):
309
+ # st.write(f"- **{r['date']}**: {r['desc']} | avg {r['temp_avg']}Β°C | wind {r['wind']} m/s")
310
+
311
+ # # ---------- Attractions & Stays ----------
312
+ # st.markdown("---")
313
+ # st.markdown('<div id="section-attractions"></div>', unsafe_allow_html=True)
314
+ # st.subheader("πŸ“ Attractions & Places to Stay")
315
+
316
+ # if st.session_state.pois is None:
317
+ # try:
318
+ # with st.spinner("Looking up attractions and stays..."):
319
+ # loc = geocode_city(st.session_state.dst_city)
320
+ # st.session_state.pois = get_attractions_and_stays(lat=loc["lat"], lon=loc["lon"], radius=8000)
321
+ # except Exception as e:
322
+ # st.error(f"Attractions lookup failed: {e}")
323
+ # st.session_state.pois = {"attractions": [], "stays": []}
324
+
325
+ # pois = st.session_state.pois
326
+ # attractions = pois.get("attractions", [])[:30]
327
+ # stays = pois.get("stays", [])[:30]
328
+
329
+ # # Select attractions
330
+ # st.write("**Top Attractions**")
331
+ # selected_attractions = []
332
+ # for i, a in enumerate(attractions[:12]):
333
+ # colA, colB = st.columns([0.8, 0.2])
334
+ # with colA:
335
+ # st.write(f"- {a['name']} ({a.get('kinds','')})")
336
+ # with colB:
337
+ # add = st.checkbox("Add", key=f"attr-{i}")
338
+ # if add:
339
+ # selected_attractions.append(a)
340
+ # if not selected_attractions:
341
+ # st.info("Tip: Select at least a few attractions you’d like to include.")
342
+
343
+ # # Stays with LLM ranking
344
+ # st.write("**Places to Stay (from OpenTripMap, no live prices)**")
345
+ # try:
346
+ # ranked_stays = rank_accommodations(stays, prefs="central, well-reviewed, convenient")
347
+ # except Exception:
348
+ # ranked_stays = stays
349
+
350
+ # stay_names = [s["name"] for s in ranked_stays[:15]]
351
+ # chosen_stay_name = st.selectbox("Pick a place to stay (optional)", options=["(None)"] + stay_names)
352
+ # selected_stay = None if chosen_stay_name == "(None)" else next(
353
+ # (s for s in ranked_stays if s["name"] == chosen_stay_name), None
354
+ # )
355
+ # st.session_state.selected_stay = selected_stay
356
+
357
+ # # ---------- Review & Booking (Mock) ----------
358
+ # st.markdown("---")
359
+ # st.markdown('<div id="section-booking"></div>', unsafe_allow_html=True)
360
+ # st.subheader("🧾 Review & Booking (Sandbox/Mock)")
361
+ # st.write("**Flight Selected**")
362
+ # sel_offer = fl["flights"][st.session_state.flight_idx]
363
+ # st.write(f"- Total: {sel_offer['price_label']}")
364
+ # st.write("**Booking is simulated for MVP (no actual ticketing)**")
365
+
366
+ # passenger_name = st.text_input("Passenger Full Name", value="Test User")
367
+ # passenger_email = st.text_input("Contact Email", value="test@example.com")
368
+ # if st.button("Reserve Flight (Mock)"):
369
+ # import uuid
370
+
371
+ # pnr = str(uuid.uuid4())[:8].upper()
372
+ # st.success(f"Flight reserved (mock). Reference: {pnr}")
373
+ # st.session_state.flight_pnr = pnr
374
+
375
+ # if st.session_state.selected_stay:
376
+ # st.write(f"**Hotel Selected:** {st.session_state.selected_stay['name']} (mock booking)")
377
+ # if st.button("Reserve Hotel (Mock)"):
378
+ # import uuid
379
+
380
+ # hid = str(uuid.uuid4())[:10].upper()
381
+ # st.success(f"Hotel reserved (mock). Ref: {hid}")
382
+ # st.session_state.hotel_ref = hid
383
+
384
+ # # ---------- Itinerary ----------
385
+ # st.markdown("---")
386
+ # st.markdown('<div id="section-itinerary"></div>', unsafe_allow_html=True)
387
+ # st.subheader("πŸ—“οΈ AI Itinerary")
388
+ # if st.button("Generate Itinerary"):
389
+ # with st.spinner("AI planning..."):
390
+ # try:
391
+ # st.session_state.itinerary_md = build_itinerary(
392
+ # st.session_state.dst_city,
393
+ # utils.to_iso(st.session_state.start_date),
394
+ # int(st.session_state.num_days),
395
+ # selected_attractions,
396
+ # st.session_state.selected_stay,
397
+ # weather.get("summary_text", ""),
398
+ # )
399
+ # # Optionally scroll to itinerary when generated
400
+ # st.session_state.scroll_target = "section-itinerary"
401
+ # except Exception as e:
402
+ # st.error(f"Itinerary generation failed: {e}")
403
+
404
+ # if st.session_state.get("itinerary_md"):
405
+ # st.markdown(st.session_state.itinerary_md)
406
+
407
+ # # ---------- Final: perform any pending scroll ----------
408
+ # if st.session_state.get("scroll_target"):
409
+ # scroll_to(st.session_state["scroll_target"])
410
+
411
+ import streamlit as st
412
+ from datetime import date
413
+ from streamlit.components.v1 import html as st_html
414
+
415
+ from planmate import utils
416
+ from planmate.config import APP_TITLE, APP_TAGLINE, THEME, CURRENCY
417
+ from planmate.flights import search_flights
418
+ from planmate.weather import summarize_forecast_for_range, geocode_city
419
+ from planmate.attractions import get_attractions_and_stays
420
+ from planmate.llm import rank_accommodations, resolve_city_to_iata_ai
421
+ from planmate.itinerary import build_itinerary
422
+
423
+ st.set_page_config(page_title=APP_TITLE, page_icon="✈️", layout="wide")
424
+
425
+ # ---------- Modern Green Theme Styles ----------
426
+ st.markdown(
427
+ """
428
+ <style>
429
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
430
+
431
+ /* Global Styles */
432
+ html, body, [class*="css"] {
433
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
434
+ }
435
+
436
+ .main .block-container {
437
+ padding-top: 2rem;
438
+ padding-bottom: 2rem;
439
+ max-width: 1200px;
440
+ }
441
+
442
+ /* Color Variables */
443
+ :root {
444
+ --primary-green: #10B981;
445
+ --primary-green-dark: #059669;
446
+ --primary-green-light: #6EE7B7;
447
+ --accent-green: #D1FAE5;
448
+ --dark-green: #064E3B;
449
+ --text-primary: #1F2937;
450
+ --text-secondary: #6B7280;
451
+ --background: #F9FAFB;
452
+ --card-background: #FFFFFF;
453
+ --border: #E5E7EB;
454
+ --border-light: #F3F4F6;
455
+ --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
456
+ --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
457
+ }
458
+
459
+ /* Main Background */
460
+ .main {
461
+ background: linear-gradient(135deg, #ECFDF5 0%, #F0FDF4 100%);
462
+ }
463
+
464
+ /* Header Styling */
465
+ .main h1 {
466
+ color: var(--dark-green);
467
+ font-weight: 700;
468
+ font-size: 2.5rem;
469
+ margin-bottom: 0.5rem;
470
+ background: linear-gradient(135deg, var(--primary-green), var(--primary-green-dark));
471
+ -webkit-background-clip: text;
472
+ -webkit-text-fill-color: transparent;
473
+ background-clip: text;
474
+ }
475
+
476
+ .main h2 {
477
+ color: var(--dark-green);
478
+ font-weight: 600;
479
+ font-size: 1.5rem;
480
+ margin-bottom: 1rem;
481
+ display: flex;
482
+ align-items: center;
483
+ gap: 0.5rem;
484
+ }
485
+
486
+ .main h3 {
487
+ color: var(--text-primary);
488
+ font-weight: 600;
489
+ font-size: 1.25rem;
490
+ margin-bottom: 0.75rem;
491
+ }
492
+
493
+ /* Card Styling */
494
+ .modern-card {
495
+ background: var(--card-background);
496
+ border-radius: 16px;
497
+ padding: 1.5rem;
498
+ margin-bottom: 1rem;
499
+ box-shadow: var(--shadow);
500
+ border: 1px solid var(--border-light);
501
+ transition: all 0.3s ease;
502
+ position: relative;
503
+ overflow: hidden;
504
+ }
505
+
506
+ .modern-card::before {
507
+ content: '';
508
+ position: absolute;
509
+ top: 0;
510
+ left: 0;
511
+ right: 0;
512
+ height: 3px;
513
+ background: linear-gradient(90deg, var(--primary-green), var(--primary-green-light));
514
+ }
515
+
516
+ .modern-card:hover {
517
+ transform: translateY(-2px);
518
+ box-shadow: var(--shadow-lg);
519
+ }
520
+
521
+ /* Success Banner */
522
+ .success-banner {
523
+ background: linear-gradient(135deg, var(--accent-green), var(--primary-green-light));
524
+ border: 1px solid var(--primary-green-light);
525
+ border-radius: 12px;
526
+ padding: 1rem 1.5rem;
527
+ margin: 1rem 0;
528
+ color: var(--dark-green);
529
+ font-weight: 500;
530
+ display: flex;
531
+ align-items: center;
532
+ gap: 0.75rem;
533
+ }
534
+
535
+ .success-banner::before {
536
+ content: 'βœ…';
537
+ font-size: 1.2rem;
538
+ }
539
+
540
+ /* Info Box */
541
+ .info-box {
542
+ background: linear-gradient(135deg, #EBF8FF, #DBEAFE);
543
+ border: 1px solid #93C5FD;
544
+ border-radius: 12px;
545
+ padding: 1rem 1.5rem;
546
+ margin: 1rem 0;
547
+ color: #1E40AF;
548
+ font-weight: 500;
549
+ display: flex;
550
+ align-items: center;
551
+ gap: 0.75rem;
552
+ }
553
+
554
+ .info-box::before {
555
+ content: 'ℹ️';
556
+ font-size: 1.2rem;
557
+ }
558
+
559
+ /* Warning Box */
560
+ .warning-box {
561
+ background: linear-gradient(135deg, #FFFBEB, #FEF3C7);
562
+ border: 1px solid #FCD34D;
563
+ border-radius: 12px;
564
+ padding: 1rem 1.5rem;
565
+ margin: 1rem 0;
566
+ color: #92400E;
567
+ font-weight: 500;
568
+ display: flex;
569
+ align-items: center;
570
+ gap: 0.75rem;
571
+ }
572
+
573
+ .warning-box::before {
574
+ content: '⚠️';
575
+ font-size: 1.2rem;
576
+ }
577
+
578
+ /* Sidebar Styling */
579
+ .css-1d391kg, .css-1y4p8pa {
580
+ background: linear-gradient(180deg, var(--card-background) 0%, #F8FAFC 100%);
581
+ border-right: 1px solid var(--border);
582
+ }
583
+
584
+ /* Button Styling */
585
+ .stButton > button {
586
+ background: linear-gradient(135deg, var(--primary-green), var(--primary-green-dark));
587
+ color: white;
588
+ border: none;
589
+ border-radius: 10px;
590
+ padding: 0.75rem 1.5rem;
591
+ font-weight: 600;
592
+ font-size: 0.95rem;
593
+ transition: all 0.3s ease;
594
+ box-shadow: 0 2px 4px rgba(16, 185, 129, 0.2);
595
+ min-height: 3rem;
596
+ }
597
+
598
+ .stButton > button:hover {
599
+ background: linear-gradient(135deg, var(--primary-green-dark), #047857);
600
+ transform: translateY(-1px);
601
+ box-shadow: 0 4px 8px rgba(16, 185, 129, 0.3);
602
+ }
603
+
604
+ .stButton > button:active {
605
+ transform: translateY(0);
606
+ }
607
+
608
+ /* Secondary Button */
609
+ .secondary-button {
610
+ background: transparent !important;
611
+ color: var(--primary-green) !important;
612
+ border: 2px solid var(--primary-green) !important;
613
+ }
614
+
615
+ .secondary-button:hover {
616
+ background: var(--accent-green) !important;
617
+ color: var(--dark-green) !important;
618
+ }
619
+
620
+ /* Input Styling */
621
+ .stTextInput > div > div > input,
622
+ .stNumberInput > div > div > input,
623
+ .stSelectbox > div > div > select,
624
+ .stDateInput > div > div > input {
625
+ border-radius: 10px;
626
+ border: 2px solid var(--border);
627
+ padding: 0.75rem;
628
+ font-size: 0.95rem;
629
+ transition: all 0.3s ease;
630
+ background: var(--card-background);
631
+ }
632
+
633
+ .stTextInput > div > div > input:focus,
634
+ .stNumberInput > div > div > input:focus,
635
+ .stSelectbox > div > div > select:focus,
636
+ .stDateInput > div > div > input:focus {
637
+ border-color: var(--primary-green);
638
+ box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
639
+ outline: none;
640
+ }
641
+
642
+ /* Checkbox Styling */
643
+ .stCheckbox > label {
644
+ font-weight: 500;
645
+ color: var(--text-primary);
646
+ }
647
+
648
+ /* Flight Card Specific */
649
+ .flight-card {
650
+ background: var(--card-background);
651
+ border-radius: 12px;
652
+ padding: 1.25rem;
653
+ margin-bottom: 1rem;
654
+ border: 2px solid var(--border-light);
655
+ transition: all 0.3s ease;
656
+ position: relative;
657
+ }
658
+
659
+ .flight-card:hover {
660
+ border-color: var(--primary-green-light);
661
+ transform: translateY(-1px);
662
+ box-shadow: var(--shadow);
663
+ }
664
+
665
+ .flight-selected {
666
+ border-color: var(--primary-green);
667
+ background: linear-gradient(135deg, var(--accent-green), #F0FDF4);
668
+ }
669
+
670
+ /* Weather Item */
671
+ .weather-item {
672
+ background: var(--card-background);
673
+ border-radius: 10px;
674
+ padding: 1rem;
675
+ margin: 0.5rem 0;
676
+ border-left: 4px solid var(--primary-green);
677
+ box-shadow: var(--shadow);
678
+ }
679
+
680
+ /* Attraction Item */
681
+ .attraction-item {
682
+ display: flex;
683
+ align-items: center;
684
+ padding: 0.75rem 1rem;
685
+ background: var(--card-background);
686
+ border-radius: 10px;
687
+ margin: 0.5rem 0;
688
+ border: 1px solid var(--border);
689
+ transition: all 0.3s ease;
690
+ }
691
+
692
+ .attraction-item:hover {
693
+ border-color: var(--primary-green-light);
694
+ transform: translateX(4px);
695
+ }
696
+
697
+ /* Section Divider */
698
+ .section-divider {
699
+ height: 2px;
700
+ background: linear-gradient(90deg, transparent, var(--primary-green-light), transparent);
701
+ margin: 2rem 0;
702
+ border: none;
703
+ }
704
+
705
+ /* Progress Bar */
706
+ .progress-container {
707
+ background: var(--border-light);
708
+ border-radius: 10px;
709
+ height: 6px;
710
+ margin: 1rem 0;
711
+ overflow: hidden;
712
+ }
713
+
714
+ .progress-bar {
715
+ background: linear-gradient(90deg, var(--primary-green), var(--primary-green-light));
716
+ height: 100%;
717
+ border-radius: 10px;
718
+ transition: width 0.3s ease;
719
+ }
720
+
721
+ /* Responsive Design */
722
+ @media (max-width: 768px) {
723
+ .main .block-container {
724
+ padding-left: 1rem;
725
+ padding-right: 1rem;
726
+ }
727
+
728
+ .main h1 {
729
+ font-size: 2rem;
730
+ }
731
+
732
+ .modern-card {
733
+ padding: 1rem;
734
+ }
735
+ }
736
+
737
+ /* Animation for loading states */
738
+ @keyframes pulse {
739
+ 0%, 100% {
740
+ opacity: 1;
741
+ }
742
+ 50% {
743
+ opacity: 0.5;
744
+ }
745
+ }
746
+
747
+ .loading {
748
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
749
+ }
750
+
751
+ /* Caption styling */
752
+ .main .css-1v0mbdj p {
753
+ color: var(--text-secondary);
754
+ font-size: 1.1rem;
755
+ margin-top: 0.5rem;
756
+ }
757
+
758
+ /* Sidebar header */
759
+ .css-1d391kg h2,
760
+ .css-1y4p8pa h2 {
761
+ color: var(--dark-green);
762
+ font-weight: 600;
763
+ }
764
+
765
+ /* Expander styling */
766
+ .streamlit-expanderHeader {
767
+ background: var(--accent-green);
768
+ border-radius: 8px;
769
+ color: var(--dark-green);
770
+ font-weight: 500;
771
+ }
772
+
773
+ /* Tab styling */
774
+ .stTabs [data-baseweb="tab-list"] {
775
+ gap: 0.5rem;
776
+ }
777
+
778
+ .stTabs [data-baseweb="tab"] {
779
+ background: var(--card-background);
780
+ border-radius: 8px;
781
+ border: 2px solid var(--border);
782
+ color: var(--text-secondary);
783
+ font-weight: 500;
784
+ }
785
+
786
+ .stTabs [data-baseweb="tab"]:hover {
787
+ border-color: var(--primary-green-light);
788
+ }
789
+
790
+ .stTabs [aria-selected="true"] {
791
+ background: var(--primary-green);
792
+ border-color: var(--primary-green);
793
+ color: white;
794
+ }
795
+ </style>
796
+ """,
797
+ unsafe_allow_html=True,
798
+ )
799
+
800
+ # ---------- Header ----------
801
+ colH1, colH2 = st.columns([0.8, 0.2])
802
+ with colH1:
803
+ # Option 1: Replace with logo image
804
+ try:
805
+ st.image("planmate-logo.png", width=250) # Adjust width as needed
806
+ st.caption(APP_TAGLINE)
807
+ except:
808
+ # Fallback if logo not found
809
+ st.title(APP_TITLE)
810
+ st.caption(APP_TAGLINE)
811
+
812
+ # Option 2: Logo with text combination (uncomment to use instead)
813
+ # col_logo, col_text = st.columns([0.3, 0.7])
814
+ # with col_logo:
815
+ # try:
816
+ # st.image("logo.png", width=80)
817
+ # except:
818
+ # st.write("πŸš€") # Fallback emoji
819
+ # with col_text:
820
+ # st.title(APP_TITLE)
821
+ # st.caption(APP_TAGLINE)
822
+
823
+ with colH2:
824
+ if st.button("πŸ”„ Start Over", use_container_width=True, help="Reset and start planning a new trip"):
825
+ for k in list(st.session_state.keys()):
826
+ del st.session_state[k]
827
+ st.rerun()
828
+
829
+ # ---------- Sidebar Inputs ----------
830
+ with st.sidebar:
831
+ st.markdown("### 🧳 Trip Planning")
832
+
833
+ with st.container():
834
+ src_city = st.text_input("πŸ›« From", value="Lahore", help="Enter your departure city")
835
+ dst_city = st.text_input("πŸ›¬ To", value="Dubai", help="Enter your destination city")
836
+
837
+ col1, col2 = st.columns(2)
838
+ with col1:
839
+ start_date = st.date_input("πŸ“… Start Date", value=date.today())
840
+ with col2:
841
+ num_days = st.number_input("πŸ“† Days", min_value=1, max_value=30, value=5, step=1)
842
+
843
+ col3, col4 = st.columns(2)
844
+ with col3:
845
+ adults = st.number_input("πŸ‘₯ Adults", min_value=1, max_value=9, value=1, step=1)
846
+ with col4:
847
+ non_stop = st.checkbox("✈️ Non-stop", value=False, help="Direct flights only")
848
+
849
+ # with st.expander("βš™οΈ Advanced Options", expanded=False):
850
+ # st.markdown("*Override AI-suggested airport codes*")
851
+ # override_origin = st.text_input("Origin IATA Code", placeholder="e.g., LHE", help="Optional: Force specific origin airport")
852
+ # override_dest = st.text_input("Destination IATA Code", placeholder="e.g., DXB", help="Optional: Force specific destination airport")
853
+
854
+ st.markdown("---")
855
+ go = st.button("πŸš€ Plan My Trip", type="primary", use_container_width=True)
856
+
857
+ # ---------- Helpers ----------
858
+ def set_planned(**kwargs):
859
+ """Persist a 'planned' flag and any key/value to session."""
860
+ st.session_state.planned = True
861
+ for k, v in kwargs.items():
862
+ st.session_state[k] = v
863
+
864
+ def not_planned():
865
+ return not st.session_state.get("planned", False)
866
+
867
+ def scroll_to(target_id: str):
868
+ """Smooth-scroll to a section with the given DOM id."""
869
+ st_html(
870
+ f"""
871
+ <script>
872
+ setTimeout(function() {{
873
+ const el = parent.document.getElementById("{target_id}");
874
+ if (el) el.scrollIntoView({{ behavior: "smooth", block: "start" }});
875
+ }}, 120);
876
+ </script>
877
+ """,
878
+ height=0,
879
+ )
880
+
881
+ # flight panel visibility toggle
882
+ if "show_flights" not in st.session_state:
883
+ st.session_state.show_flights = False
884
+
885
+ # ---------- First click: plan & fetch flights ----------
886
+ if go:
887
+ # Country hints (optional) to improve AI IATA mapping
888
+ src_country = None
889
+ dst_country = None
890
+ try:
891
+ src_country = geocode_city(src_city).get("country")
892
+ except Exception:
893
+ pass
894
+ try:
895
+ dst_country = geocode_city(dst_city).get("country")
896
+ except Exception:
897
+ pass
898
+
899
+ # Resolve to IATA via AI, with optional overrides
900
+ try:
901
+ if override_origin.strip():
902
+ origin_code, origin_label, origin_kind = (
903
+ override_origin.strip().upper(),
904
+ f"(override) {override_origin.strip().upper()}",
905
+ "OVERRIDE",
906
+ )
907
+ else:
908
+ code, name, kind = resolve_city_to_iata_ai(src_city, src_country)
909
+ origin_code, origin_label, origin_kind = code, f"{name} ({code})", kind
910
+
911
+ if override_dest.strip():
912
+ dest_code, dest_label, dest_kind = (
913
+ override_dest.strip().upper(),
914
+ f"(override) {override_dest.strip().upper()}",
915
+ "OVERRIDE",
916
+ )
917
+ else:
918
+ code, name, kind = resolve_city_to_iata_ai(dst_city, dst_country)
919
+ dest_code, dest_label, dest_kind = code, f"{name} ({code})", kind
920
+
921
+ except Exception as e:
922
+ st.markdown(
923
+ f"""
924
+ <div class="warning-box">
925
+ Either city was not found or it does not have an airport: {str(e)}
926
+ </div>
927
+ """,
928
+ unsafe_allow_html=True
929
+ )
930
+ st.stop()
931
+
932
+ # Compute return date
933
+ ret_date = utils.compute_return_date(start_date, int(num_days))
934
+
935
+ # Fetch flights once and store
936
+ try:
937
+ with st.spinner("πŸ” Searching for the best flights..."):
938
+ flights = search_flights(
939
+ origin_code,
940
+ dest_code,
941
+ utils.to_iso(start_date),
942
+ utils.to_iso(ret_date),
943
+ int(adults),
944
+ CURRENCY,
945
+ non_stop=non_stop,
946
+ )
947
+ except Exception as e:
948
+ st.markdown(
949
+ f"""
950
+ <div class="warning-box">
951
+ Flight search failed: {str(e)}
952
+ </div>
953
+ """,
954
+ unsafe_allow_html=True
955
+ )
956
+ st.stop()
957
+
958
+ set_planned(
959
+ src_city=src_city,
960
+ dst_city=dst_city,
961
+ start_date=start_date,
962
+ num_days=int(num_days),
963
+ adults=int(adults),
964
+ non_stop=bool(non_stop),
965
+ origin_code=origin_code,
966
+ origin_label=origin_label,
967
+ origin_kind=origin_kind,
968
+ dest_code=dest_code,
969
+ dest_label=dest_label,
970
+ dest_kind=dest_kind,
971
+ ret_date=ret_date,
972
+ flights=flights,
973
+ flight_idx=None,
974
+ show_flights=True,
975
+ weather=None,
976
+ pois=None,
977
+ selected_stay=None,
978
+ itinerary_md=None,
979
+ scroll_target=None,
980
+ )
981
+
982
+ # ---------- Render results if we have a planned trip ----------
983
+ if not_planned():
984
+ st.markdown(
985
+ """
986
+ <div class="info-box">
987
+ Fill in your trip details in the sidebar and click <strong>Plan My Trip</strong> to get started with your adventure!
988
+ </div>
989
+ """,
990
+ unsafe_allow_html=True
991
+ )
992
+ st.stop()
993
+
994
+ # Banner for resolved IATA
995
+ st.markdown(
996
+ f"""
997
+ <div class="success-banner">
998
+ <strong>Routes Resolved:</strong> {st.session_state.src_city} β†’ {st.session_state.origin_label} [{st.session_state.origin_kind}] β€’
999
+ {st.session_state.dst_city} β†’ {st.session_state.dest_label} [{st.session_state.dest_kind}]
1000
+ </div>
1001
+ """,
1002
+ unsafe_allow_html=True
1003
+ )
1004
+
1005
+ # ---------- Flights ----------
1006
+ st.markdown('<hr class="section-divider">', unsafe_allow_html=True)
1007
+ st.markdown('<div id="section-flights"></div>', unsafe_allow_html=True)
1008
+ st.subheader("✈️ Flight Options")
1009
+
1010
+ fl = st.session_state.get("flights", {"flights": []})
1011
+
1012
+ # If user has selected a flight, show a compact summary
1013
+ if st.session_state.get("flight_idx") is not None:
1014
+ sel_offer = fl["flights"][st.session_state.flight_idx]
1015
+ st.markdown(
1016
+ f"""
1017
+ <div class="modern-card flight-selected">
1018
+ <h4>🎯 Selected Flight β€” {sel_offer['price_label']}</h4>
1019
+ </div>
1020
+ """,
1021
+ unsafe_allow_html=True
1022
+ )
1023
+
1024
+ for leg_i, segs in enumerate(sel_offer["legs"]):
1025
+ st.write(f"**✈️ Leg {leg_i+1}**")
1026
+ for s in segs:
1027
+ st.write(f"β€’ {s['from']} β†’ {s['to']} | {s['dep']} β†’ {s['arr']} | {s['carrier']} {s['number']}")
1028
+
1029
+ if st.button("πŸ”„ Change Flight", help="Select a different flight option"):
1030
+ st.session_state.flight_idx = None
1031
+ st.session_state.show_flights = True
1032
+ st.session_state.weather = None
1033
+ st.session_state.pois = None
1034
+ st.session_state.itinerary_md = None
1035
+ st.session_state.scroll_target = "section-flights"
1036
+ st.rerun()
1037
+
1038
+ else:
1039
+ if not fl.get("flights"):
1040
+ st.markdown(
1041
+ """
1042
+ <div class="warning-box">
1043
+ No flight offers found for the selected dates/route. Try different dates or cities.
1044
+ </div>
1045
+ """,
1046
+ unsafe_allow_html=True
1047
+ )
1048
+ else:
1049
+ if st.session_state.get("show_flights", True):
1050
+ for idx, offer in enumerate(fl["flights"][:10]):
1051
+ st.markdown(
1052
+ f"""
1053
+ <div class="flight-card">
1054
+ <h4>πŸ›« Option {idx+1} β€” {offer['price_label']}</h4>
1055
+ """,
1056
+ unsafe_allow_html=True
1057
+ )
1058
+
1059
+ for leg_i, segs in enumerate(offer["legs"]):
1060
+ st.write(f"**Leg {leg_i+1}**")
1061
+ for s in segs:
1062
+ st.write(f"β€’ {s['from']} β†’ {s['to']} | {s['dep']} β†’ {s['arr']} | {s['carrier']} {s['number']}")
1063
+
1064
+ if st.button("βœ… Select This Flight", key=f"select-flight-{idx}"):
1065
+ st.session_state.flight_idx = idx
1066
+ st.session_state.show_flights = False
1067
+ st.session_state.weather = None
1068
+ st.session_state.pois = None
1069
+ st.session_state.itinerary_md = None
1070
+ st.session_state.scroll_target = "section-weather"
1071
+ st.rerun()
1072
+
1073
+ st.markdown("</div>", unsafe_allow_html=True)
1074
+ else:
1075
+ if st.button("πŸ‘€ Show Flight Options"):
1076
+ st.session_state.show_flights = True
1077
+ st.rerun()
1078
+
1079
+ # Gate further steps until a flight is selected
1080
+ if st.session_state.get("flight_idx") is None:
1081
+ st.markdown(
1082
+ """
1083
+ <div class="info-box">
1084
+ Select a flight to proceed to weather, attractions, and itinerary planning.
1085
+ </div>
1086
+ """,
1087
+ unsafe_allow_html=True
1088
+ )
1089
+ if st.session_state.get("scroll_target"):
1090
+ scroll_to(st.session_state["scroll_target"])
1091
+ st.session_state["scroll_target"] = None
1092
+ st.stop()
1093
+
1094
+ # ---------- Weather ----------
1095
+ st.markdown('<hr class="section-divider">', unsafe_allow_html=True)
1096
+ st.markdown('<div id="section-weather"></div>', unsafe_allow_html=True)
1097
+ st.subheader("🌦️ Weather Forecast")
1098
+
1099
+ if st.session_state.weather is None:
1100
+ try:
1101
+ with st.spinner("🌀️ Fetching weather forecast..."):
1102
+ st.session_state.weather = summarize_forecast_for_range(
1103
+ st.session_state.dst_city, st.session_state.start_date, st.session_state.num_days
1104
+ )
1105
+ except Exception as e:
1106
+ st.markdown(
1107
+ f"""
1108
+ <div class="warning-box">
1109
+ Weather data unavailable: {str(e)}
1110
+ </div>
1111
+ """,
1112
+ unsafe_allow_html=True
1113
+ )
1114
+ st.session_state.weather = {"summary_text": "Weather data not available", "daily": []}
1115
+
1116
+ weather = st.session_state.weather
1117
+ if "location" in weather:
1118
+ st.markdown(
1119
+ f"""
1120
+ <div class="success-banner">
1121
+ πŸ“ {weather['location'].get('name')}, {weather['location'].get('country')}
1122
+ </div>
1123
+ """,
1124
+ unsafe_allow_html=True
1125
+ )
1126
+
1127
+ for r in weather.get("daily", []):
1128
+ st.markdown(
1129
+ f"""
1130
+ <div class="weather-item">
1131
+ <strong>{r['date']}</strong>: {r['desc']} β€’ {r['temp_avg']}Β°C β€’ Wind {r['wind']} m/s
1132
+ </div>
1133
+ """,
1134
+ unsafe_allow_html=True
1135
+ )
1136
+
1137
+ # ---------- Attractions & Stays ----------
1138
+ st.markdown('<hr class="section-divider">', unsafe_allow_html=True)
1139
+ st.markdown('<div id="section-attractions"></div>', unsafe_allow_html=True)
1140
+ st.subheader("πŸ“ Attractions & Accommodations")
1141
+
1142
+ if st.session_state.pois is None:
1143
+ try:
1144
+ with st.spinner("πŸ—ΊοΈ Discovering attractions and accommodations..."):
1145
+ loc = geocode_city(st.session_state.dst_city)
1146
+ st.session_state.pois = get_attractions_and_stays(lat=loc["lat"], lon=loc["lon"], radius=8000)
1147
+ except Exception as e:
1148
+ st.markdown(
1149
+ f"""
1150
+ <div class="warning-box">
1151
+ Attractions lookup failed: {str(e)}
1152
+ </div>
1153
+ """,
1154
+ unsafe_allow_html=True
1155
+ )
1156
+ st.session_state.pois = {"attractions": [], "stays": []}
1157
+
1158
+ pois = st.session_state.pois
1159
+ attractions = pois.get("attractions", [])[:30]
1160
+ stays = pois.get("stays", [])[:30]
1161
+
1162
+ # Select attractions
1163
+ st.markdown("#### 🎯 **Top Attractions**")
1164
+ selected_attractions = []
1165
+ for i, a in enumerate(attractions[:12]):
1166
+ col1, col2 = st.columns([0.85, 0.15])
1167
+ with col1:
1168
+ st.markdown(
1169
+ f"""
1170
+ <div class="attraction-item">
1171
+ πŸ“Œ <strong>{a['name']}</strong> <em>({a.get('kinds','')})</em>
1172
+ </div>
1173
+ """,
1174
+ unsafe_allow_html=True
1175
+ )
1176
+ with col2:
1177
+ add = st.checkbox("Add", key=f"attr-{i}", help=f"Include {a['name']} in your itinerary")
1178
+ if add:
1179
+ selected_attractions.append(a)
1180
+
1181
+ if not selected_attractions:
1182
+ st.markdown(
1183
+ """
1184
+ <div class="info-box">
1185
+ πŸ’‘ Select attractions you'd like to visit for a personalized itinerary.
1186
+ </div>
1187
+ """,
1188
+ unsafe_allow_html=True
1189
+ )
1190
+
1191
+ # Stays with LLM ranking
1192
+ st.markdown("#### 🏨 **Accommodations**")
1193
+ st.caption("*Powered by OpenTripMap - for reference only, no live booking*")
1194
+
1195
+ try:
1196
+ ranked_stays = rank_accommodations(stays, prefs="central, well-reviewed, convenient")
1197
+ except Exception:
1198
+ ranked_stays = stays
1199
+
1200
+ stay_names = [s["name"] for s in ranked_stays[:15]]
1201
+ chosen_stay_name = st.selectbox(
1202
+ "Choose accommodation",
1203
+ options=["(None selected)"] + stay_names,
1204
+ help="Select a place to stay for your trip"
1205
+ )
1206
+ selected_stay = None if chosen_stay_name == "(None selected)" else next(
1207
+ (s for s in ranked_stays if s["name"] == chosen_stay_name), None
1208
+ )
1209
+ st.session_state.selected_stay = selected_stay
1210
+
1211
+ # ---------- Review & Booking (Mock) ----------
1212
+ st.markdown('<hr class="section-divider">', unsafe_allow_html=True)
1213
+ st.markdown('<div id="section-booking"></div>', unsafe_allow_html=True)
1214
+ st.subheader("🎫 Review & Booking")
1215
+
1216
+ st.markdown("#### **Flight Summary**")
1217
+ sel_offer = fl["flights"][st.session_state.flight_idx]
1218
+ st.markdown(
1219
+ f"""
1220
+ <div class="modern-card">
1221
+ <h4>πŸ’° Total Cost: {sel_offer['price_label']}</h4>
1222
+ <p><em>⚠️ This is a demo environment - no actual booking or payment processed</em></p>
1223
+ </div>
1224
+ """,
1225
+ unsafe_allow_html=True
1226
+ )
1227
+
1228
+ col1, col2 = st.columns(2)
1229
+ with col1:
1230
+ passenger_name = st.text_input("πŸ‘€ Full Name", value="Test User", help="Passenger name for booking")
1231
+ with col2:
1232
+ passenger_email = st.text_input("πŸ“§ Email", value="test@example.com", help="Contact email")
1233
+
1234
+ if st.button("🎫 Reserve Flight (Demo)", help="Simulate flight booking"):
1235
+ import uuid
1236
+ pnr = str(uuid.uuid4())[:8].upper()
1237
+ st.markdown(
1238
+ f"""
1239
+ <div class="success-banner">
1240
+ πŸŽ‰ Flight reserved! Reference: <strong>{pnr}</strong>
1241
+ </div>
1242
+ """,
1243
+ unsafe_allow_html=True
1244
+ )
1245
+ st.session_state.flight_pnr = pnr
1246
+
1247
+ if st.session_state.selected_stay:
1248
+ st.markdown(f"#### **Hotel Selected:** {st.session_state.selected_stay['name']}")
1249
+ if st.button("🏨 Reserve Hotel (Demo)", help="Simulate hotel booking"):
1250
+ import uuid
1251
+ hid = str(uuid.uuid4())[:10].upper()
1252
+ st.markdown(
1253
+ f"""
1254
+ <div class="success-banner">
1255
+ 🏨 Hotel reserved! Reference: <strong>{hid}</strong>
1256
+ </div>
1257
+ """,
1258
+ unsafe_allow_html=True
1259
+ )
1260
+ st.session_state.hotel_ref = hid
1261
+
1262
+ # ---------- Itinerary ----------
1263
+ st.markdown('<hr class="section-divider">', unsafe_allow_html=True)
1264
+ st.markdown('<div id="section-itinerary"></div>', unsafe_allow_html=True)
1265
+ st.subheader("πŸ—“οΈ AI Itinerary")
1266
+
1267
+ if st.button("πŸ€– Generate Itinerary", help="Create a personalized day-by-day itinerary using AI"):
1268
+ with st.spinner("🧠 AI planning your perfect itinerary..."):
1269
+ try:
1270
+ st.session_state.itinerary_md = build_itinerary(
1271
+ st.session_state.dst_city,
1272
+ utils.to_iso(st.session_state.start_date),
1273
+ int(st.session_state.num_days),
1274
+ selected_attractions,
1275
+ st.session_state.selected_stay,
1276
+ weather.get("summary_text", ""),
1277
+ )
1278
+ # Optionally scroll to itinerary when generated
1279
+ st.session_state.scroll_target = "section-itinerary"
1280
+ except Exception as e:
1281
+ st.markdown(
1282
+ f"""
1283
+ <div class="warning-box">
1284
+ Itinerary generation failed: {str(e)}
1285
+ </div>
1286
+ """,
1287
+ unsafe_allow_html=True
1288
+ )
1289
+
1290
+ if st.session_state.get("itinerary_md"):
1291
+ st.markdown(
1292
+ """
1293
+ <div class="modern-card">
1294
+ <h4>πŸ“‹ Your Personalized Itinerary</h4>
1295
+ </div>
1296
+ """,
1297
+ unsafe_allow_html=True
1298
+ )
1299
+ st.markdown(st.session_state.itinerary_md)
1300
+
1301
+ # ---------- Final: perform any pending scroll ----------
1302
+ if st.session_state.get("scroll_target"):
1303
+ scroll_to(st.session_state["scroll_target"])
planmate-logo.png ADDED
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ streamlit==1.38.0
2
+ google-generativeai==0.7.2
3
+ requests==2.32.3
4
+ python-dotenv==1.0.1
5
+ pytz==2024.1