rajkhanke commited on
Commit
bbd7d3c
·
verified ·
1 Parent(s): 56daeec

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +544 -0
  2. pizza.csv +0 -0
  3. templates/index.html +606 -0
app.py ADDED
@@ -0,0 +1,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, jsonify, current_app
2
+ import pandas as pd
3
+ import numpy as np
4
+ from sklearn.preprocessing import MinMaxScaler
5
+ from sklearn.metrics.pairwise import cosine_similarity
6
+ import os
7
+ import logging
8
+
9
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]')
10
+ logger = logging.getLogger(__name__)
11
+
12
+ app = Flask(__name__)
13
+
14
+ DF = None
15
+ ALL_TOPPINGS = []
16
+ FEATURE_DF = None
17
+ SCALER = None
18
+ NUMERICAL_COLS = ['Price', 'Slices', 'Rating', 'Spice_Level', 'Preparation_Time', 'Calories']
19
+ CATEGORICAL_FEATURES = [
20
+ 'Serving_Size', 'Popular_Group', 'Dietary_Category',
21
+ 'Sauce_Type', 'Cheese_Amount', 'Restaurant_Chain',
22
+ 'Seasonal_Availability', 'Bread_Type'
23
+ ]
24
+ CRUST_TYPE_COL = None
25
+ DEFAULT_IMAGE_URL = 'https://images.dominos.co.in/new_margherita_2502.jpg'
26
+
27
+
28
+ def preprocess_data(df_path='pizza.csv'):
29
+ global DF, ALL_TOPPINGS, FEATURE_DF, SCALER, CATEGORICAL_FEATURES, CRUST_TYPE_COL
30
+
31
+ if not os.path.exists(df_path):
32
+ logger.error(f"Dataset file '{df_path}' not found.")
33
+ raise FileNotFoundError(f"Dataset file '{df_path}' not found.")
34
+
35
+ DF = pd.read_csv(df_path)
36
+ logger.info(f"Original DataFrame columns: {DF.columns.tolist()}")
37
+
38
+ potential_crust_cols = ['Crust_Type', 'Cr_Type']
39
+ valid_crust_cols = [col for col in potential_crust_cols if col in DF.columns]
40
+ if valid_crust_cols:
41
+ valid_crust_cols.sort(key=lambda col: DF[col].isnull().sum())
42
+ CRUST_TYPE_COL = valid_crust_cols[0]
43
+ logger.info(f"Using '{CRUST_TYPE_COL}' for crust type.")
44
+ if CRUST_TYPE_COL not in CATEGORICAL_FEATURES:
45
+ CATEGORICAL_FEATURES.append(CRUST_TYPE_COL)
46
+ for col in potential_crust_cols:
47
+ if col != CRUST_TYPE_COL and col in CATEGORICAL_FEATURES:
48
+ CATEGORICAL_FEATURES.remove(col)
49
+ else:
50
+ logger.warning("Crust type column not found. Crust type will not be used.")
51
+ CRUST_TYPE_COL = None
52
+
53
+ text_categorical_cols = list(
54
+ set(CATEGORICAL_FEATURES + ['Toppings', 'Description', 'Allergens', 'Image_Url', 'Pizza_Name']))
55
+ for col in text_categorical_cols:
56
+ if col in DF.columns:
57
+ DF[col] = DF[col].fillna('')
58
+
59
+ numerical_cols_in_df = ['Price_Rs', 'Slices', 'Rating', 'Rating_Count', 'Preparation_Time_min',
60
+ 'Calories_per_Slice']
61
+ for col in numerical_cols_in_df:
62
+ if col in DF.columns:
63
+ if pd.api.types.is_numeric_dtype(DF[col]):
64
+ DF[col] = DF[col].fillna(DF[col].median())
65
+ else:
66
+ DF[col] = pd.to_numeric(DF[col], errors='coerce').fillna(
67
+ DF[col].median() if pd.api.types.is_numeric_dtype(DF[col]) else 0)
68
+
69
+ if 'Rating_Count' in DF.columns: DF['Rating_Count'] = DF['Rating_Count'].fillna(0).astype(int)
70
+
71
+ DF['Toppings_list_internal'] = DF['Toppings'].astype(str).str.split(
72
+ ';\\s*')
73
+ DF['Toppings_list_internal'] = DF['Toppings_list_internal'].apply(
74
+ lambda x: [t.strip() for t in x if isinstance(t, str) and t.strip()])
75
+
76
+ current_all_toppings = set()
77
+ for toppings_list in DF['Toppings_list_internal'].dropna():
78
+ current_all_toppings.update(t for t in toppings_list if t)
79
+ ALL_TOPPINGS = sorted(list(current_all_toppings))
80
+ logger.info(f"Found {len(ALL_TOPPINGS)} unique toppings. Example: {ALL_TOPPINGS[:5]}")
81
+
82
+ feature_data = {}
83
+ num_feature_map = {
84
+ 'Price': 'Price_Rs', 'Slices': 'Slices', 'Rating': 'Rating',
85
+ 'Preparation_Time': 'Preparation_Time_min', 'Calories': 'Calories_per_Slice'
86
+ }
87
+ for feature_col, df_col in num_feature_map.items():
88
+ if df_col in DF.columns:
89
+ feature_data[feature_col] = DF[df_col].copy()
90
+ else:
91
+ feature_data[feature_col] = pd.Series([0.0] * len(DF))
92
+
93
+ if 'Spice_Level' in DF.columns:
94
+ DF['Spice_Level'] = DF['Spice_Level'].fillna('Mild')
95
+ spice_map = {'Mild': 1, 'Medium': 2, 'Hot': 3}
96
+ feature_data['Spice_Level'] = DF['Spice_Level'].map(spice_map).fillna(1.0)
97
+ else:
98
+ feature_data['Spice_Level'] = pd.Series([1.0] * len(DF))
99
+
100
+ for feature_cat_col in CATEGORICAL_FEATURES:
101
+ if feature_cat_col in DF.columns:
102
+ for value in DF[feature_cat_col].unique():
103
+ if pd.notnull(value) and value != '':
104
+ feature_data[f"{feature_cat_col}_{value}"] = (DF[feature_cat_col] == value).astype(int)
105
+
106
+ for topping in ALL_TOPPINGS:
107
+ if topping:
108
+ feature_data[f"Topping_{topping}"] = DF['Toppings_list_internal'].apply(
109
+ lambda x: 1 if topping in x else 0
110
+ )
111
+
112
+ FEATURE_DF = pd.DataFrame(feature_data)
113
+ for col in NUMERICAL_COLS:
114
+ if col not in FEATURE_DF.columns: FEATURE_DF[col] = 0.0
115
+ if FEATURE_DF[col].isnull().any():
116
+ FEATURE_DF[col] = FEATURE_DF[col].fillna(
117
+ FEATURE_DF[col].mean() if pd.notna(FEATURE_DF[col].mean()) else 0.0)
118
+
119
+ SCALER = MinMaxScaler()
120
+ FEATURE_DF[NUMERICAL_COLS] = SCALER.fit_transform(FEATURE_DF[NUMERICAL_COLS])
121
+ logger.info(f"Preproc done. FEATURE_DF shape: {FEATURE_DF.shape}")
122
+
123
+
124
+ def get_recommendations(preferences):
125
+ global DF, FEATURE_DF, SCALER, CRUST_TYPE_COL, DEFAULT_IMAGE_URL
126
+
127
+ if FEATURE_DF is None or SCALER is None or DF is None:
128
+ current_app.logger.error("Data not fully initialized for get_recommendations.")
129
+ return []
130
+
131
+ current_indices = DF.index.to_list()
132
+ current_app.logger.info(f"Starting with {len(current_indices)} pizzas before filtering. Preferences: {preferences}")
133
+
134
+ # 1. Toppings (OR logic if multiple selected)
135
+ if 'toppings' in preferences and preferences['toppings']:
136
+ selected_toppings = set(preferences['toppings'])
137
+ if selected_toppings: # Ensure it's not an empty list
138
+ topping_mask = DF.loc[current_indices, 'Toppings_list_internal'].apply(
139
+ lambda x: any(t in selected_toppings for t in x))
140
+ current_indices = DF.loc[current_indices][topping_mask].index.to_list()
141
+ current_app.logger.info(f"After toppings filter: {len(current_indices)} pizzas remaining")
142
+ if not current_indices: return []
143
+
144
+ # 2. Max Price
145
+ if 'price_range' in preferences and preferences['price_range'] and 'Price_Rs' in DF.columns:
146
+ min_price = float(preferences['price_range'][0])
147
+ max_price = float(preferences['price_range'][1])
148
+ price_mask = (DF.loc[current_indices, 'Price_Rs'] >= min_price) & \
149
+ (DF.loc[current_indices, 'Price_Rs'] <= max_price)
150
+ current_indices = DF.loc[current_indices][price_mask].index.to_list()
151
+ current_app.logger.info(
152
+ f"After price filter ({min_price}-{max_price}): {len(current_indices)} pizzas remaining")
153
+ if not current_indices: return []
154
+
155
+ # 3. Number of Slices (>= selected)
156
+ if 'slices' in preferences and preferences['slices'] is not None and 'Slices' in DF.columns:
157
+ try:
158
+ min_slices = int(preferences['slices'])
159
+ slices_mask = DF.loc[current_indices, 'Slices'] >= min_slices
160
+ current_indices = DF.loc[current_indices][slices_mask].index.to_list()
161
+ current_app.logger.info(f"After slices filter (>= {min_slices}): {len(current_indices)} pizzas remaining")
162
+ if not current_indices: return []
163
+ except ValueError:
164
+ current_app.logger.warning(f"Invalid value for slices: {preferences['slices']}")
165
+
166
+ # 4. Minimum Rating (>= selected)
167
+ if 'rating' in preferences and preferences['rating'] is not None and 'Rating' in DF.columns:
168
+ try:
169
+ min_rating = float(preferences['rating'])
170
+ rating_mask = DF.loc[current_indices, 'Rating'] >= min_rating
171
+ current_indices = DF.loc[current_indices][rating_mask].index.to_list()
172
+ current_app.logger.info(f"After rating filter (>= {min_rating}): {len(current_indices)} pizzas remaining")
173
+ if not current_indices: return []
174
+ except ValueError:
175
+ current_app.logger.warning(f"Invalid value for rating: {preferences['rating']}")
176
+
177
+ # 5. Max Preparation Time (<= selected)
178
+ if 'prep_time' in preferences and preferences[
179
+ 'prep_time'] is not None and 'Preparation_Time_min' in DF.columns: # Changed 'preptime' to 'prep_time' to match JS
180
+ try:
181
+ prep_time_str = str(preferences['prep_time']).lower().replace("min", "").strip()
182
+ max_prep_time = int(prep_time_str)
183
+ prep_mask = DF.loc[current_indices, 'Preparation_Time_min'] <= max_prep_time
184
+ current_indices = DF.loc[current_indices][prep_mask].index.to_list()
185
+ current_app.logger.info(
186
+ f"After prep time filter (<= {max_prep_time}): {len(current_indices)} pizzas remaining")
187
+ if not current_indices: return []
188
+ except ValueError:
189
+ current_app.logger.warning(f"Could not parse preptime value: {preferences['prep_time']}")
190
+
191
+ # 6. Categorical Filters (Exact Match or Multi-select with OR logic)
192
+ categorical_pref_map = {
193
+ "servingsize": "Serving_Size", "populargroup": "Popular_Group",
194
+ "dietarycategory": "Dietary_Category", "spicelevel": "Spice_Level",
195
+ "saucetype": "Sauce_Type", "cheeseamount": "Cheese_Amount",
196
+ "restaurantchain": "Restaurant_Chain", "seasonalavailability": "Seasonal_Availability",
197
+ "breadtype": "Bread_Type", "crusttype": CRUST_TYPE_COL
198
+ }
199
+
200
+ for pref_key, df_col_name in categorical_pref_map.items():
201
+ if df_col_name and pref_key in preferences and preferences[pref_key] and df_col_name in DF.columns:
202
+ pref_value = preferences[pref_key]
203
+
204
+ # If pref_value is a list (from multi-select) and not empty
205
+ if isinstance(pref_value, list) and pref_value:
206
+ cat_mask = DF.loc[current_indices, df_col_name].isin(pref_value)
207
+ filtered_indices_count_before = len(current_indices)
208
+ current_indices = DF.loc[current_indices][cat_mask].index.to_list()
209
+ current_app.logger.info(
210
+ f"After {pref_key} filter (isin {pref_value}): {len(current_indices)} from {filtered_indices_count_before} pizzas remaining")
211
+ # Legacy: if it's a single string (though frontend should send list now)
212
+ elif isinstance(pref_value, str) and pref_value and pref_value.lower() != "any":
213
+ cat_mask = DF.loc[current_indices, df_col_name] == pref_value
214
+ filtered_indices_count_before = len(current_indices)
215
+ current_indices = DF.loc[current_indices][cat_mask].index.to_list()
216
+ current_app.logger.info(
217
+ f"After {pref_key} filter ('{pref_value}'): {len(current_indices)} from {filtered_indices_count_before} pizzas remaining")
218
+ elif not pref_value: # Empty list or empty string means no filter for this category
219
+ current_app.logger.info(
220
+ f"Skipping filter for {pref_key} as no specific options were selected (value: {pref_value}).")
221
+ continue
222
+
223
+ if not current_indices: return []
224
+
225
+ if not current_indices:
226
+ current_app.logger.warning("No pizzas match all filter criteria after hard filters.")
227
+ return []
228
+
229
+ # --- Similarity Scoring Part ---
230
+ valid_indices_for_feature_df = FEATURE_DF.index.intersection(current_indices)
231
+ if valid_indices_for_feature_df.empty:
232
+ current_app.logger.warning("No valid indices remain for feature DF after hard filters.")
233
+ return []
234
+
235
+ filtered_feature_df = FEATURE_DF.loc[valid_indices_for_feature_df]
236
+ if filtered_feature_df.empty:
237
+ current_app.logger.warning("Filtered feature DF is empty after hard filters.")
238
+ return []
239
+
240
+ user_vector = pd.Series(0.0, index=FEATURE_DF.columns)
241
+
242
+ # Toppings for similarity
243
+ if 'toppings' in preferences and preferences['toppings']:
244
+ for topping in preferences['toppings']:
245
+ col_name = f"Topping_{topping}"
246
+ if col_name in user_vector.index:
247
+ user_vector[col_name] = 1.0
248
+
249
+ # Categorical for similarity
250
+ js_to_df_key_map_for_vector = {
251
+ "servingsize": "Serving_Size", "populargroup": "Popular_Group",
252
+ "dietarycategory": "Dietary_Category", "saucetype": "Sauce_Type",
253
+ "cheeseamount": "Cheese_Amount", "restaurantchain": "Restaurant_Chain",
254
+ "seasonalavailability": "Seasonal_Availability", "breadtype": "Bread_Type",
255
+ "spicelevel": "Spice_Level" # Add spicelevel here for one-hot encoding
256
+ }
257
+ if CRUST_TYPE_COL: js_to_df_key_map_for_vector["crusttype"] = CRUST_TYPE_COL
258
+
259
+ for pref_key, df_col_name in js_to_df_key_map_for_vector.items():
260
+ if pref_key in preferences and preferences[pref_key]:
261
+ pref_values_for_vector = preferences[pref_key]
262
+ # Ensure it's a list, even if frontend sent a single string (should be list)
263
+ if not isinstance(pref_values_for_vector, list):
264
+ pref_values_for_vector = [pref_values_for_vector]
265
+
266
+ for val_item in pref_values_for_vector:
267
+ if isinstance(val_item, str) and val_item.lower() == "any": # Should not happen with new UI
268
+ continue
269
+ col_name = f"{df_col_name}_{val_item}"
270
+ if col_name in user_vector.index:
271
+ user_vector[col_name] = 1.0
272
+
273
+ # Numerical for similarity
274
+ raw_user_num_prefs_dict = {}
275
+ spice_map = {'Mild': 1, 'Medium': 2, 'Hot': 3}
276
+
277
+ if 'price_range' in preferences and preferences['price_range']:
278
+ raw_user_num_prefs_dict['Price'] = (float(preferences['price_range'][0]) + float(
279
+ preferences['price_range'][1])) / 2
280
+ if 'slices' in preferences and preferences['slices'] is not None:
281
+ raw_user_num_prefs_dict['Slices'] = float(preferences['slices'])
282
+ if 'rating' in preferences and preferences['rating'] is not None:
283
+ raw_user_num_prefs_dict['Rating'] = float(preferences['rating'])
284
+
285
+ # Handle numerical Spice_Level for user_vector
286
+ # Only set if exactly one spice level is chosen in the multi-select.
287
+ # The one-hot encoded versions are handled above.
288
+ if 'spicelevel' in preferences and preferences['spicelevel']:
289
+ selected_spice_levels = preferences['spicelevel']
290
+ if isinstance(selected_spice_levels, list) and len(selected_spice_levels) == 1:
291
+ # If only one specific spice level selected from multi-select
292
+ spice_val_str = selected_spice_levels[0]
293
+ if spice_val_str and spice_val_str.lower() != "any":
294
+ raw_user_num_prefs_dict['Spice_Level'] = float(spice_map.get(spice_val_str, 1))
295
+ # If multiple spice levels or "Any" (empty list), don't set numerical Spice_Level for user_vector.
296
+ # The one-hot encoded versions will cover the preference.
297
+
298
+ if 'prep_time' in preferences and preferences['prep_time'] is not None: # Changed 'preptime'
299
+ try:
300
+ prep_time_str = str(preferences['prep_time']).lower().replace("min", "").strip()
301
+ raw_user_num_prefs_dict['Preparation_Time'] = float(prep_time_str)
302
+ except ValueError:
303
+ pass
304
+
305
+ # Scaling numerical preferences for user_vector
306
+ temp_scaling_df = pd.DataFrame(columns=NUMERICAL_COLS, index=[0])
307
+ for col in NUMERICAL_COLS:
308
+ temp_scaling_df.loc[0, col] = raw_user_num_prefs_dict.get(col, 0.0) # Use default if not in dict
309
+
310
+ # Ensure all NUMERICAL_COLS exist in temp_scaling_df before transform
311
+ for col in NUMERICAL_COLS:
312
+ if col not in temp_scaling_df.columns:
313
+ temp_scaling_df[col] = 0.0 # Default to 0 or mean if appropriate
314
+
315
+ scaled_user_num_values = SCALER.transform(temp_scaling_df[NUMERICAL_COLS])[0]
316
+ for i, col_name in enumerate(NUMERICAL_COLS):
317
+ if col_name in raw_user_num_prefs_dict: # Only set if user specified this numerical pref
318
+ user_vector[col_name] = scaled_user_num_values[i]
319
+
320
+ # Similarity calculation
321
+ feature_matrix_filtered = filtered_feature_df.values
322
+ user_array = user_vector.values.reshape(1, -1)
323
+
324
+ if user_array.shape[1] != feature_matrix_filtered.shape[1]:
325
+ current_app.logger.error(
326
+ f"Shape mismatch! User vector: {user_array.shape}, Feature matrix: {feature_matrix_filtered.shape}")
327
+ # This can happen if new columns were added to FEATURE_DF after user_vector was initialized
328
+ # Re-align user_vector to FEATURE_DF.columns
329
+ aligned_user_vector = pd.Series(0.0, index=FEATURE_DF.columns)
330
+ for col in user_vector.index:
331
+ if col in aligned_user_vector.index:
332
+ aligned_user_vector[col] = user_vector[col]
333
+ user_array = aligned_user_vector.values.reshape(1, -1)
334
+ if user_array.shape[1] != feature_matrix_filtered.shape[1]:
335
+ current_app.logger.error(
336
+ f"Persistent Shape mismatch! User vector: {user_array.shape}, Feature matrix: {feature_matrix_filtered.shape}")
337
+ return []
338
+
339
+ similarities = cosine_similarity(user_array, feature_matrix_filtered)[0]
340
+ sorted_indices_in_filtered_df = similarities.argsort()[::-1]
341
+ final_recommendation_indices = valid_indices_for_feature_df[sorted_indices_in_filtered_df]
342
+
343
+ recommendations_list = []
344
+ frontend_keys = [
345
+ 'id', 'name', 'toppings', 'price', 'slices', 'serving_size', 'rating', 'rating_count',
346
+ 'description', 'popular_group', 'dietary_category', 'spice_level', 'sauce_type',
347
+ 'cheese_amount', 'calories', 'allergens', 'prep_time', 'restaurant', 'seasonal',
348
+ 'bread_type', 'image_url', 'crust_type'
349
+ ]
350
+ df_to_frontend_map = {
351
+ 'id': None, 'name': 'Pizza_Name', 'toppings': 'Toppings', 'price': 'Price_Rs', 'slices': 'Slices',
352
+ 'serving_size': 'Serving_Size', 'rating': 'Rating', 'rating_count': 'Rating_Count',
353
+ 'description': 'Description', 'popular_group': 'Popular_Group',
354
+ 'dietary_category': 'Dietary_Category', 'spice_level': 'Spice_Level',
355
+ 'sauce_type': 'Sauce_Type', 'cheese_amount': 'Cheese_Amount',
356
+ 'calories': 'Calories_per_Slice', 'allergens': 'Allergens',
357
+ 'prep_time': 'Preparation_Time_min', 'restaurant': 'Restaurant_Chain',
358
+ 'seasonal': 'Seasonal_Availability', 'bread_type': 'Bread_Type',
359
+ 'image_url': 'Image_Url', 'crust_type': CRUST_TYPE_COL
360
+ }
361
+
362
+ for original_idx in final_recommendation_indices:
363
+ pizza_series = DF.iloc[original_idx]
364
+ rec_item = {}
365
+ for key in frontend_keys:
366
+ df_col = df_to_frontend_map.get(key)
367
+ if key == 'id':
368
+ rec_item[key] = int(original_idx)
369
+ elif df_col and df_col in pizza_series:
370
+ value = pizza_series[df_col]
371
+ if isinstance(value, np.integer):
372
+ value = int(value)
373
+ elif isinstance(value, np.floating):
374
+ value = float(value)
375
+ elif isinstance(value, np.ndarray):
376
+ value = value.tolist()
377
+ rec_item[key] = "" if pd.isna(value) else value
378
+ elif key == 'crust_type' and not CRUST_TYPE_COL:
379
+ rec_item[key] = "N/A"
380
+ else:
381
+ rec_item[key] = ""
382
+
383
+ rec_item['rating_count'] = int(rec_item.get('rating_count', 0) or 0)
384
+ rec_item['image_url'] = rec_item.get('image_url') if rec_item.get('image_url') else DEFAULT_IMAGE_URL
385
+
386
+ for k_final, v_final in rec_item.items():
387
+ if isinstance(v_final, np.generic): rec_item[k_final] = v_final.item()
388
+
389
+ recommendations_list.append(rec_item)
390
+
391
+ current_app.logger.info(f"Final recommendations: {len(recommendations_list)} pizzas")
392
+ return recommendations_list
393
+
394
+
395
+ @app.route('/')
396
+ def index_route():
397
+ global DF, ALL_TOPPINGS, CATEGORICAL_FEATURES, CRUST_TYPE_COL
398
+ if DF is None:
399
+ current_app.logger.error("Data not loaded attempting to serve / route.")
400
+ return "Error: Pizza data not loaded. Please check server logs.", 500
401
+
402
+ filter_options = {}
403
+ cols_for_filters = list(
404
+ set(CATEGORICAL_FEATURES + ['Spice_Level'])) # Spice_Level might be in CATEGORICAL_FEATURES or separate
405
+
406
+ if CRUST_TYPE_COL and CRUST_TYPE_COL not in cols_for_filters: # Ensure crust type is included if available
407
+ cols_for_filters.append(CRUST_TYPE_COL)
408
+
409
+ for col_name in cols_for_filters:
410
+ if col_name in DF.columns:
411
+ # Use a consistent key naming convention for JS
412
+ key_name = col_name.lower().replace('_', '')
413
+ # Special cases for consistency if needed, e.g. "spicelevel"
414
+ if col_name == "Spice_Level": key_name = "spicelevel"
415
+ if col_name == CRUST_TYPE_COL: key_name = "crusttype"
416
+ # if col_name == "Serving_Size": key_name = "servingsize" # Example
417
+
418
+ unique_values = sorted([v for v in DF[col_name].dropna().unique() if v != ''])
419
+ filter_options[key_name] = unique_values # e.g. filter_options['spicelevel'] = ['Mild', 'Medium', 'Hot']
420
+
421
+ default_recommendations_df = DF.sort_values('Rating', ascending=False).copy()
422
+ default_recs_list = []
423
+
424
+ frontend_keys = [
425
+ 'id', 'name', 'toppings', 'price', 'slices', 'serving_size', 'rating', 'rating_count',
426
+ 'description', 'popular_group', 'dietary_category', 'spice_level', 'sauce_type',
427
+ 'cheese_amount', 'calories', 'allergens', 'prep_time', 'restaurant', 'seasonal',
428
+ 'bread_type', 'image_url', 'crust_type'
429
+ ]
430
+ df_to_frontend_map = {
431
+ 'id': None, 'name': 'Pizza_Name', 'toppings': 'Toppings', 'price': 'Price_Rs', 'slices': 'Slices',
432
+ 'serving_size': 'Serving_Size', 'rating': 'Rating', 'rating_count': 'Rating_Count',
433
+ 'description': 'Description', 'popular_group': 'Popular_Group',
434
+ 'dietary_category': 'Dietary_Category', 'spice_level': 'Spice_Level',
435
+ 'sauce_type': 'Sauce_Type', 'cheese_amount': 'Cheese_Amount',
436
+ 'calories': 'Calories_per_Slice', 'allergens': 'Allergens',
437
+ 'prep_time': 'Preparation_Time_min', 'restaurant': 'Restaurant_Chain',
438
+ 'seasonal': 'Seasonal_Availability', 'bread_type': 'Bread_Type',
439
+ 'image_url': 'Image_Url', 'crust_type': CRUST_TYPE_COL
440
+ }
441
+
442
+ for original_idx, pizza_row in default_recommendations_df.iterrows():
443
+ rec_item = {}
444
+ for key in frontend_keys:
445
+ df_col = df_to_frontend_map.get(key)
446
+ if key == 'id':
447
+ rec_item[key] = int(original_idx)
448
+ elif df_col and df_col in pizza_row:
449
+ value = pizza_row[df_col]
450
+ if isinstance(value, np.integer):
451
+ value = int(value)
452
+ elif isinstance(value, np.floating):
453
+ value = float(value)
454
+ elif isinstance(value, np.ndarray):
455
+ value = value.tolist()
456
+ rec_item[key] = "" if pd.isna(value) else value
457
+ elif key == 'crust_type' and not CRUST_TYPE_COL:
458
+ rec_item[key] = "N/A"
459
+ else:
460
+ rec_item[key] = ""
461
+
462
+ rec_item['rating_count'] = int(rec_item.get('rating_count', 0) or 0)
463
+ rec_item['image_url'] = rec_item.get('image_url') if rec_item.get('image_url') else DEFAULT_IMAGE_URL
464
+
465
+ for k, v in rec_item.items():
466
+ if isinstance(v, np.generic):
467
+ rec_item[k] = v.item()
468
+
469
+ default_recs_list.append(rec_item)
470
+
471
+ current_app.logger.info(f"Serving {len(default_recs_list)} pizzas for initial display.")
472
+ current_app.logger.info(f"Filter options for template: {filter_options}")
473
+
474
+ return render_template('index.html',
475
+ toppings=ALL_TOPPINGS,
476
+ # Pass filter_options directly, JS will use these
477
+ filter_options=filter_options,
478
+ default_recommendations=default_recs_list,
479
+ default_image_url=DEFAULT_IMAGE_URL)
480
+
481
+
482
+ @app.route('/recommend', methods=['POST'])
483
+ def recommend():
484
+ try:
485
+ data = request.json
486
+ preferences = {}
487
+ current_app.logger.info(f"Received recommendation request with data: {data}")
488
+
489
+ # Process all possible preferences
490
+ # Keys should match what JS sends (e.g., 'servingsize', 'spicelevel')
491
+ # Numerical/range preferences
492
+ simple_numerical_prefs = ['slices', 'rating', 'prep_time'] # 'prep_time' not 'preptime'
493
+ for key in simple_numerical_prefs:
494
+ if key in data and data[key] is not None: # Allow 0 for rating
495
+ # For range sliders, value might be a string that needs parsing, ensure it's correct type
496
+ try:
497
+ if key == 'rating':
498
+ preferences[key] = float(data[key])
499
+ else:
500
+ preferences[key] = int(data[key]) # slices, prep_time
501
+ except ValueError:
502
+ current_app.logger.warning(f"Could not parse numerical preference {key}: {data[key]}")
503
+
504
+ if 'price_range' in data and data['price_range']:
505
+ try:
506
+ preferences['price_range'] = [float(p) for p in data['price_range']]
507
+ except (ValueError, TypeError):
508
+ current_app.logger.warning(f"Could not parse price_range: {data['price_range']}")
509
+
510
+ # Multi-select categorical preferences (including toppings)
511
+ # Keys like 'toppings', 'servingsize', 'dietarycategory', 'spicelevel', etc.
512
+ multi_select_prefs = [
513
+ 'toppings', 'servingsize', 'populargroup', 'dietarycategory',
514
+ 'spicelevel', 'saucetype', 'cheeseamount', 'restaurantchain',
515
+ 'seasonalavailability', 'breadtype', 'crusttype'
516
+ ]
517
+ for key in multi_select_prefs:
518
+ if key in data and isinstance(data[key], list): # Expecting a list
519
+ preferences[key] = data[key] # Store the list (can be empty)
520
+ elif key in data: # If not a list, log warning but try to process if it's a single string
521
+ current_app.logger.warning(
522
+ f"Preference for {key} was not a list: {data[key]}. Processing as single if string.")
523
+ if isinstance(data[key], str) and data[key]:
524
+ preferences[key] = [data[key]] # Wrap single string in a list for consistency
525
+ else: # If not string or empty, treat as no preference for this key
526
+ preferences[key] = []
527
+
528
+ current_app.logger.info(f"Processed preferences for filtering: {preferences}")
529
+ recommendations = get_recommendations(preferences)
530
+ current_app.logger.info(f"Returning {len(recommendations)} recommendations after filtering and scoring.")
531
+ return jsonify(recommendations)
532
+ except Exception as e:
533
+ current_app.logger.error(f"Error in /recommend: {e}", exc_info=True)
534
+ return jsonify({"error": "Failed to get recommendations due to a server issue.", "details": str(e)}), 500
535
+
536
+
537
+ if __name__ == '__main__':
538
+ try:
539
+ preprocess_data()
540
+ app.run(debug=True, use_reloader=False) # use_reloader=False is good for dev with global vars
541
+ except FileNotFoundError as e:
542
+ logger.critical(f"CRITICAL ERROR: {e}. Ensure 'pizza.csv' is present.")
543
+ except Exception as e:
544
+ logger.critical(f"Unexpected critical startup error: {e}", exc_info=True)
pizza.csv ADDED
The diff for this file is too large to render. See raw diff
 
templates/index.html ADDED
@@ -0,0 +1,606 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Pizza Recommendation System</title>
7
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
8
+ <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <style>
11
+ :root {
12
+ --primary: #FF6B35;
13
+ --primary-dark: #E85D2C;
14
+ --secondary: #FFF3E0;
15
+ --text-dark: #333333;
16
+ --text-light: #FFFFFF;
17
+ }
18
+ body {
19
+ font-family: 'Poppins', sans-serif;
20
+ background-color: #FFF8F0;
21
+ color: var(--text-dark);
22
+ background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23FF6B35' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
23
+ }
24
+ .header { background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); box-shadow: 0 4px 15px rgba(255, 107, 53, 0.2); }
25
+ .logo { font-weight: 700; font-size: 1.8rem; color: var(--text-light); text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); }
26
+ .logo i { margin-right: 8px; }
27
+ .card { background-color: white; border-radius: 12px; overflow: hidden; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); transition: all 0.3s ease; cursor: pointer; }
28
+ .card:hover { transform: translateY(-8px); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); }
29
+ .card-image { height: 180px; overflow: hidden; position: relative; background-color: #f0f0f0; }
30
+ .card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s ease; }
31
+ .card:hover .card-image img { transform: scale(1.05); }
32
+ .price-tag { position: absolute; top: 12px; right: 12px; background-color: var(--primary); color: var(--text-light); padding: 4px 10px; border-radius: 20px; font-weight: 600; font-size: 0.9rem; box-shadow: 0 4px 6px rgba(255, 107, 53, 0.25); }
33
+ .card-title { font-size: 1.2rem; font-weight: 600; color: var(--primary-dark); }
34
+ .rating { display: flex; align-items: center; }
35
+ .rating i { color: #FFB800; margin-right: 4px; }
36
+ .filter-section { background-color: white; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
37
+ .filter-header { background-color: var(--primary); color: var(--text-light); padding: 15px 20px; border-top-left-radius: 12px; border-top-right-radius: 12px; font-weight: 600; display: flex; justify-content: space-between; align-items: center; }
38
+ .form-label { font-weight: 500; margin-bottom: 6px; color: var(--text-dark); display: block; }
39
+ .btn-primary { background-color: var(--primary); color: var(--text-light); padding: 10px 20px; border-radius: 8px; font-weight: 600; transition: all 0.3s ease; box-shadow: 0 4px 6px rgba(255, 107, 53, 0.25); }
40
+ .btn-primary:hover { background-color: var(--primary-dark); transform: translateY(-2px); box-shadow: 0 6px 8px rgba(255, 107, 53, 0.3); }
41
+ .btn-outline { border: 2px solid var(--primary); color: var(--primary); padding: 8px 18px; border-radius: 8px; font-weight: 600; transition: all 0.3s ease; }
42
+ .btn-outline:hover { background-color: var(--primary); color: var(--text-light); }
43
+ input[type="range"].range-slider { width: 100%; -webkit-appearance: none; appearance: none; height: 8px; background: #ddd; border-radius: 5px; outline: none; opacity: 0.7; transition: opacity .2s; cursor: pointer;}
44
+ input[type="range"].range-slider:hover { opacity: 1;}
45
+ input[type="range"].range-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 20px; height: 20px; background: var(--primary); border-radius: 50%; cursor: pointer; }
46
+ input[type="range"].range-slider::-moz-range-thumb { width: 20px; height: 20px; background: var(--primary); border-radius: 50%; cursor: pointer; border: none; }
47
+ .multiselect-dropdown { position: relative; }
48
+ .multiselect-dropdown .selected-input { border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 12px; width: 100%; background-color: #f8fafc; transition: all 0.3s ease; }
49
+ .multiselect-dropdown .selected-input:focus-within, .multiselect-dropdown .selected-input.active { border-color: var(--primary); outline: none; box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.2); }
50
+ .multiselect-dropdown .dropdown-container { display: none; position: absolute; top: 100%; left: 0; width: 100%; max-height: 200px; overflow-y: auto; background-color: white; border: 1px solid #e2e8f0; border-radius: 0 0 8px 8px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); z-index: 10; padding: 8px; margin-top: -1px; }
51
+ .multiselect-dropdown .dropdown-container.active { display: block; }
52
+ .multiselect-dropdown .option-item, .topping-item { padding: 6px 10px; cursor: pointer; border-radius: 4px; margin-bottom: 4px; transition: all 0.2s ease; }
53
+ .multiselect-dropdown .option-item:hover, .topping-item:hover { background-color: var(--secondary); }
54
+ .multiselect-dropdown .option-item.selected, .topping-item.selected { background-color: var(--primary); color: var(--text-light); }
55
+ .selected-options-display, .selected-toppings { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
56
+ .selected-tag, .selected-topping { background-color: var(--secondary); color: var(--primary-dark); padding: 4px 10px; border-radius: 20px; font-size: 0.8rem; display: flex; align-items: center; gap: 6px; }
57
+ .selected-tag i, .selected-topping i { cursor: pointer; }
58
+ .range-values { display: flex; justify-content: space-between; margin-top: 8px; font-size: 0.9rem; color: #64748b; }
59
+ .toggle-filters { display: none; }
60
+ .pizza-badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; margin-right: 6px; margin-bottom: 6px; }
61
+ .badge-veg { background-color: #DCFCE7; color: #16A34A; }
62
+ .badge-non-veg { background-color: #FEE2E2; color: #DC2626; }
63
+ .badge-vegan { background-color: #E0F2FE; color: #0284C7; }
64
+ .badge-spice { background-color: #FEF3C7; color: #D97706; }
65
+ .loader { border: 4px solid #f3f3f3; border-top: 4px solid var(--primary); border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 0 auto; }
66
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
67
+ .line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
68
+ @media (max-width: 1023px) { .toggle-filters { display: block; background: none; border: none; color: white; font-size: 1.2rem; } .filters-container { max-height: 0; overflow: hidden; transition: max-height 0.5s ease-in-out, padding 0.5s ease-in-out; padding: 0 1.5rem; } .filters-container.active { max-height: 2000px; padding: 1.5rem; } }
69
+ .modal-item strong { color: var(--primary-dark); }
70
+ </style>
71
+ </head>
72
+ <body>
73
+ <header class="header py-4 px-6 mb-8">
74
+ <div class="container mx-auto flex items-center justify-between">
75
+ <div class="flex items-center">
76
+ <span class="logo"><i class="fas fa-pizza-slice"></i>Pizza Recommendation System</span>
77
+ </div>
78
+ <div class="hidden md:block">
79
+ <p class="text-white text-sm">Find your perfect pizza match!</p>
80
+ </div>
81
+ </div>
82
+ </header>
83
+
84
+ <div class="container mx-auto px-4 mb-10">
85
+ <div class="flex flex-wrap -mx-4">
86
+ <div class="w-full lg:w-1/4 px-4 mb-8 lg:mb-0">
87
+ <div class="filter-section">
88
+ <div class="filter-header">
89
+ <h2 class="text-lg">Customize Your Pizza</h2>
90
+ <button class="toggle-filters lg:hidden"><i class="fas fa-sliders-h"></i></button>
91
+ </div>
92
+ <div class="filters-container p-6">
93
+ <form id="pizza-filters">
94
+ <!-- Toppings Selection -->
95
+ <div class="mb-6">
96
+ <label class="form-label">Toppings</label>
97
+ <div class="multiselect-dropdown" data-filter-key="toppings">
98
+ <div class="selected-input p-2 rounded-lg cursor-pointer flex items-center justify-between">
99
+ <span>Select toppings</span><i class="fas fa-chevron-down text-sm"></i>
100
+ </div>
101
+ <div class="dropdown-container">
102
+ <input type="text" class="w-full mb-2 p-2 border border-gray-200 rounded text-sm" placeholder="Search toppings...">
103
+ <div class="options-list toppings-list">
104
+ {% for topping in toppings %}<div class="option-item topping-item text-sm" data-value="{{ topping }}">{{ topping }}</div>{% endfor %}
105
+ </div>
106
+ </div>
107
+ <div class="selected-options-display selected-toppings mt-2"></div>
108
+ </div>
109
+ </div>
110
+
111
+ <div class="mb-6">
112
+ <label for="price-range" class="form-label">Max Price (₹)</label>
113
+ <input type="range" id="price-range" name="price_range_max" min="199" max="1999" step="50" class="range-slider" value="1999">
114
+ <div class="range-values"><span>₹199</span><span id="price-value">₹1999</span></div>
115
+ </div>
116
+
117
+ <div class="mb-6">
118
+ <label for="slices" class="form-label">Min. Number of Slices</label>
119
+ <input type="range" id="slices" name="slices" min="4" max="12" step="1" class="range-slider" value="4">
120
+ <div class="range-values"><span>4</span><span id="slices-value">4</span><span>12</span></div>
121
+ </div>
122
+
123
+ <div class="mb-6">
124
+ <label for="rating" class="form-label">Minimum Rating</label>
125
+ <input type="range" id="rating" name="rating" min="0" max="5" step="0.5" class="range-slider" value="0">
126
+ <div class="flex justify-between items-center mt-2"><div class="flex items-center" id="rating-display"></div></div>
127
+ </div>
128
+
129
+ <div class="mb-6">
130
+ <label for="prep-time" class="form-label">Max Preparation Time (min)</label>
131
+ <input type="range" id="prep-time" name="prep_time" min="12" max="90" step="1" class="range-slider" value="90">
132
+ <div class="range-values"><span>12 min</span><span id="prep-time-value">90 min</span><span>90 min</span></div>
133
+ </div>
134
+
135
+ <!-- Categorical Filters - Transformed to Multi-Select -->
136
+ {% set filter_map = {
137
+ 'servingsize': {'label': 'Serving Size', 'options': filter_options.servingsize},
138
+ 'dietarycategory': {'label': 'Dietary Category', 'options': filter_options.dietarycategory},
139
+ 'spicelevel': {'label': 'Spice Level', 'options': filter_options.spicelevel},
140
+ 'crusttype': {'label': 'Crust Type', 'options': filter_options.crusttype},
141
+ 'populargroup': {'label': 'Popular Among', 'options': filter_options.populargroup},
142
+ 'saucetype': {'label': 'Sauce Type', 'options': filter_options.saucetype},
143
+ 'cheeseamount': {'label': 'Cheese Amount', 'options': filter_options.cheeseamount},
144
+ 'restaurantchain': {'label': 'Restaurant Chain', 'options': filter_options.restaurantchain},
145
+ 'seasonalavailability': {'label': 'Seasonal Availability', 'options': filter_options.seasonalavailability},
146
+ 'breadtype': {'label': 'Bread Type', 'options': filter_options.breadtype}
147
+ } %}
148
+
149
+ {% for key, item in filter_map.items() %}
150
+ {% if item.options %}
151
+ <div class="mb-6">
152
+ <label class="form-label">{{ item.label }}</label>
153
+ <div class="multiselect-dropdown" data-filter-key="{{ key }}">
154
+ <div class="selected-input p-2 rounded-lg cursor-pointer flex items-center justify-between">
155
+ <span>Select {{ item.label.lower() }}(s)</span>
156
+ <i class="fas fa-chevron-down text-sm"></i>
157
+ </div>
158
+ <div class="dropdown-container">
159
+ <div class="options-list">
160
+ {% for option_val in item.options %}
161
+ <div class="option-item text-sm" data-value="{{ option_val }}">{{ option_val }}</div>
162
+ {% endfor %}
163
+ </div>
164
+ </div>
165
+ <div class="selected-options-display mt-2"></div>
166
+ </div>
167
+ </div>
168
+ {% endif %}
169
+ {% endfor %}
170
+
171
+ <div class="flex justify-between mt-8">
172
+ <button type="submit" class="btn-primary text-sm"><i class="fas fa-search mr-2"></i>Find Pizza</button>
173
+ <button type="reset" class="btn-outline text-sm"><i class="fas fa-redo mr-1"></i>Reset</button>
174
+ </div>
175
+ </form>
176
+ </div>
177
+ </div>
178
+ </div>
179
+
180
+ <div class="w-full lg:w-3/4 px-4">
181
+ <div class="bg-white p-6 rounded-lg shadow mb-6">
182
+ <h2 class="text-2xl font-bold mb-1 text-gray-800">Recommended Pizzas</h2>
183
+ <p class="text-gray-600 mb-4 text-sm" id="recommendation-subtitle">Discover pizzas tailored to your taste!</p>
184
+ <div id="loading" class="hidden py-6"><div class="loader"></div><p class="text-center mt-4 text-gray-600 text-sm">Finding your perfect pizza match...</p></div>
185
+ <div id="pizza-recommendations" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"></div>
186
+ </div>
187
+ </div>
188
+ </div>
189
+ </div>
190
+
191
+ <!-- Pizza Detail Modal (structure remains mostly the same) -->
192
+ <div id="pizza-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center p-4 z-50 transition-opacity duration-300 ease-in-out opacity-0">
193
+ <div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-3xl max-h-[90vh] overflow-y-auto transform scale-95 transition-transform duration-300 ease-in-out">
194
+ <div class="flex justify-between items-center mb-4 border-b pb-3">
195
+ <h2 id="modal-pizza-name" class="text-2xl font-bold text-primary">Pizza Name</h2>
196
+ <button id="modal-close-btn" class="text-gray-500 hover:text-gray-800 text-3xl leading-none">×</button>
197
+ </div>
198
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
199
+ <div class="md:pr-4">
200
+ <img id="modal-pizza-image" src="{{ default_image_url }}" alt="Pizza Image" class="w-full h-64 object-cover rounded-lg mb-4 shadow" onerror="this.onerror=null;this.src='{{ default_image_url }}';">
201
+ <div class="flex justify-between items-center mb-2">
202
+ <p class="text-3xl font-semibold text-primary-dark">₹<span id="modal-pizza-price"></span></p>
203
+ <div class="flex items-center">
204
+ <div id="modal-pizza-rating-stars" class="rating mr-2 text-xl"></div>
205
+ <span id="modal-pizza-rating-text" class="text-sm text-gray-600"></span>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ <div class="space-y-2 text-sm md:pl-4 border-t md:border-t-0 md:border-l pt-4 md:pt-0">
210
+ <p class="modal-item"><strong>Description:</strong><br><span id="modal-pizza-description" class="text-gray-700 block mt-1"></span></p>
211
+ <p class="modal-item"><strong>Toppings:</strong><br><span id="modal-pizza-toppings" class="text-gray-700 block mt-1"></span></p>
212
+ <p class="modal-item"><strong>Slices:</strong> <span id="modal-pizza-slices" class="text-gray-700"></span></p>
213
+ <p class="modal-item"><strong>Serving Size:</strong> <span id="modal-pizza-serving-size" class="text-gray-700"></span></p>
214
+ <p class="modal-item"><strong>Dietary Category:</strong> <span id="modal-pizza-dietary" class="text-gray-700"></span></p>
215
+ <p class="modal-item"><strong>Spice Level:</strong> <span id="modal-pizza-spice" class="text-gray-700"></span></p>
216
+ <p class="modal-item"><strong>Crust Type:</strong> <span id="modal-pizza-crust-type" class="text-gray-700"></span></p>
217
+ <p class="modal-item"><strong>Sauce Type:</strong> <span id="modal-pizza-sauce" class="text-gray-700"></span></p>
218
+ <p class="modal-item"><strong>Cheese Amount:</strong> <span id="modal-pizza-cheese" class="text-gray-700"></span></p>
219
+ <p class="modal-item"><strong>Calories per Slice:</strong> <span id="modal-pizza-calories" class="text-gray-700"></span></p>
220
+ <p class="modal-item"><strong>Preparation Time:</strong> <span id="modal-pizza-prep-time" class="text-gray-700"></span> min</p>
221
+ <p class="modal-item"><strong>Restaurant:</strong> <span id="modal-pizza-restaurant" class="text-gray-700"></span></p>
222
+ <p class="modal-item"><strong>Popular Group:</strong> <span id="modal-pizza-popular-group" class="text-gray-700"></span></p>
223
+ <p class="modal-item"><strong>Seasonal Availability:</strong> <span id="modal-pizza-seasonal" class="text-gray-700"></span></p>
224
+ <p class="modal-item"><strong>Bread Type:</strong> <span id="modal-pizza-bread" class="text-gray-700"></span></p>
225
+ <p class="modal-item"><strong>Allergens:</strong><br><span id="modal-pizza-allergens" class="text-gray-700 block mt-1"></span></p>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </div>
230
+
231
+ <script>
232
+ const DEFAULT_IMAGE_URL_JS = "{{ default_image_url }}";
233
+ let allPizzasData = {{ default_recommendations | tojson }};
234
+ const allFilterOptions = {{ filter_options | tojson }}; // Get all filter options from Flask
235
+
236
+ document.addEventListener('DOMContentLoaded', function() {
237
+ // --- Range Sliders ---
238
+ const priceRange = document.getElementById('price-range');
239
+ const priceValue = document.getElementById('price-value');
240
+ if(priceRange && priceValue) { priceRange.addEventListener('input', () => priceValue.textContent = `₹${priceRange.value}`); priceValue.textContent = `₹${priceRange.value}`; }
241
+
242
+ const slicesRange = document.getElementById('slices');
243
+ const slicesValue = document.getElementById('slices-value');
244
+ if(slicesRange && slicesValue) { slicesRange.addEventListener('input', () => slicesValue.textContent = slicesRange.value); slicesValue.textContent = slicesRange.value; }
245
+
246
+ const prepTimeRange = document.getElementById('prep-time');
247
+ const prepTimeValue = document.getElementById('prep-time-value');
248
+ if(prepTimeRange && prepTimeValue) { prepTimeRange.addEventListener('input', () => prepTimeValue.textContent = `${prepTimeRange.value} min`); prepTimeValue.textContent = `${prepTimeRange.value} min`; }
249
+
250
+ const ratingRange = document.getElementById('rating');
251
+ const ratingDisplay = document.getElementById('rating-display');
252
+ if(ratingRange && ratingDisplay) { ratingRange.addEventListener('input', () => updateRatingStars(ratingRange.value, ratingDisplay)); updateRatingStars(ratingRange.value, ratingDisplay); }
253
+
254
+ function updateRatingStars(rating, displayElement, isModal = false) {
255
+ if (!displayElement) return;
256
+ let starsHTML = ''; const r = parseFloat(rating);
257
+ for (let i = 0; i < 5; i++) {
258
+ if (i < Math.floor(r)) starsHTML += '<i class="fas fa-star text-yellow-400"></i>';
259
+ else if (i < r) starsHTML += '<i class="fas fa-star-half-alt text-yellow-400"></i>';
260
+ else starsHTML += '<i class="far fa-star text-yellow-400"></i>';
261
+ }
262
+ if (isModal) displayElement.innerHTML = starsHTML;
263
+ else displayElement.innerHTML = starsHTML + `<span class="ml-2 text-sm text-gray-600">(${r.toFixed(1)})</span>`;
264
+ }
265
+
266
+ // --- Mobile Filters Toggle ---
267
+ const toggleFiltersBtn = document.querySelector('.toggle-filters');
268
+ const filtersContainerEl = document.querySelector('.filters-container');
269
+ if (toggleFiltersBtn && filtersContainerEl) {
270
+ toggleFiltersBtn.addEventListener('click', () => {
271
+ filtersContainerEl.classList.toggle('active');
272
+ toggleFiltersBtn.querySelector('i').classList.toggle('fa-sliders-h');
273
+ toggleFiltersBtn.querySelector('i').classList.toggle('fa-times');
274
+ });
275
+ }
276
+
277
+ // --- Multi-Select Dropdown Logic ---
278
+ let selectedCategoricalFilters = {}; // Stores { filterKey: [val1, val2], ... }
279
+ const multiSelectDropdowns = document.querySelectorAll('.multiselect-dropdown');
280
+
281
+ multiSelectDropdowns.forEach(dropdownEl => {
282
+ const filterKey = dropdownEl.dataset.filterKey;
283
+ selectedCategoricalFilters[filterKey] = []; // Initialize
284
+
285
+ const selectInput = dropdownEl.querySelector('.selected-input');
286
+ const dropdownContainer = dropdownEl.querySelector('.dropdown-container');
287
+ const optionsList = dropdownEl.querySelector('.options-list');
288
+ const searchInput = dropdownEl.querySelector('input[type="text"]'); // For toppings search
289
+
290
+ selectInput.addEventListener('click', () => {
291
+ dropdownContainer.classList.toggle('active');
292
+ selectInput.classList.toggle('active');
293
+ });
294
+
295
+ optionsList.querySelectorAll('.option-item').forEach(item => {
296
+ item.addEventListener('click', function() {
297
+ const value = this.dataset.value;
298
+ const currentSelections = selectedCategoricalFilters[filterKey];
299
+ if (currentSelections.includes(value)) {
300
+ selectedCategoricalFilters[filterKey] = currentSelections.filter(v => v !== value);
301
+ this.classList.remove('selected');
302
+ } else {
303
+ currentSelections.push(value);
304
+ this.classList.add('selected');
305
+ }
306
+ updateSelectedTagsDisplay(dropdownEl, filterKey);
307
+ if (filterKey !== 'toppings') { // Auto-close for non-topping simple selects
308
+ // dropdownContainer.classList.remove('active');
309
+ // selectInput.classList.remove('active');
310
+ }
311
+ });
312
+ });
313
+
314
+ if (searchInput) { // Toppings search
315
+ searchInput.addEventListener('input', function() {
316
+ const searchValue = this.value.toLowerCase();
317
+ optionsList.querySelectorAll('.option-item').forEach(item => {
318
+ item.style.display = item.textContent.toLowerCase().includes(searchValue) ? '' : 'none';
319
+ });
320
+ });
321
+ }
322
+ });
323
+
324
+ // Close dropdowns if clicked outside
325
+ document.addEventListener('click', (e) => {
326
+ multiSelectDropdowns.forEach(dropdownEl => {
327
+ if (!dropdownEl.contains(e.target)) {
328
+ dropdownEl.querySelector('.dropdown-container').classList.remove('active');
329
+ dropdownEl.querySelector('.selected-input').classList.remove('active');
330
+ }
331
+ });
332
+ });
333
+
334
+ function updateSelectedTagsDisplay(dropdownEl, filterKey) {
335
+ const selectedDisplayContainer = dropdownEl.querySelector('.selected-options-display');
336
+ const selectInputTextSpan = dropdownEl.querySelector('.selected-input span');
337
+ const currentSelections = selectedCategoricalFilters[filterKey];
338
+ const filterConfig = Array.from(multiSelectDropdowns).find(el => el.dataset.filterKey === filterKey);
339
+ const label = filterConfig ? (filterConfig.closest('.mb-6').querySelector('.form-label')?.textContent || filterKey) : filterKey;
340
+
341
+
342
+ selectedDisplayContainer.innerHTML = '';
343
+ currentSelections.forEach(value => {
344
+ const tag = document.createElement('div');
345
+ tag.className = 'selected-tag'; // Generic class for tags
346
+ tag.innerHTML = `<span>${value}</span><i class="fas fa-times ml-1 text-xs" data-value="${value}"></i>`;
347
+ tag.querySelector('i').addEventListener('click', function() {
348
+ const valToRemove = this.dataset.value;
349
+ selectedCategoricalFilters[filterKey] = selectedCategoricalFilters[filterKey].filter(v => v !== valToRemove);
350
+ dropdownEl.querySelector(`.option-item[data-value="${valToRemove}"]`)?.classList.remove('selected');
351
+ updateSelectedTagsDisplay(dropdownEl, filterKey);
352
+ });
353
+ selectedDisplayContainer.appendChild(tag);
354
+ });
355
+
356
+ if (currentSelections.length > 0) {
357
+ selectInputTextSpan.textContent = `${currentSelections.length} ${label.toLowerCase()}(s) selected`;
358
+ } else {
359
+ selectInputTextSpan.textContent = `Select ${label.toLowerCase()}(s)`;
360
+ }
361
+ }
362
+
363
+ // --- Form Submission & Reset ---
364
+ const pizzaFiltersForm = document.getElementById('pizza-filters');
365
+ const pizzaRecommendationsEl = document.getElementById('pizza-recommendations');
366
+ const loadingIndicator = document.getElementById('loading');
367
+ const recommendationSubtitle = document.getElementById('recommendation-subtitle');
368
+
369
+ if(pizzaFiltersForm) {
370
+ pizzaFiltersForm.addEventListener('submit', function(e) { e.preventDefault(); fetchRecommendations(); });
371
+ pizzaFiltersForm.addEventListener('reset', function() {
372
+ // Reset range sliders
373
+ if(priceRange && priceValue) { priceRange.value = "1999"; priceValue.textContent = `₹1999`; }
374
+ if(slicesRange && slicesValue) { slicesRange.value = "4"; slicesValue.textContent = "4"; }
375
+ if(prepTimeRange && prepTimeValue) { prepTimeRange.value = "90"; prepTimeValue.textContent = `90 min`; }
376
+ if(ratingRange && ratingDisplay) { ratingRange.value = "0"; updateRatingStars("0", ratingDisplay); }
377
+
378
+ // Reset multi-selects
379
+ Object.keys(selectedCategoricalFilters).forEach(key => {
380
+ selectedCategoricalFilters[key] = [];
381
+ const dropdownEl = document.querySelector(`.multiselect-dropdown[data-filter-key="${key}"]`);
382
+ if (dropdownEl) {
383
+ dropdownEl.querySelectorAll('.option-item.selected').forEach(el => el.classList.remove('selected'));
384
+ updateSelectedTagsDisplay(dropdownEl, key);
385
+ }
386
+ });
387
+ if (document.querySelector('.multiselect-dropdown[data-filter-key="toppings"] input[type="text"]')) {
388
+ document.querySelector('.multiselect-dropdown[data-filter-key="toppings"] input[type="text"]').value = ""; // Clear search
389
+ document.querySelectorAll('.multiselect-dropdown[data-filter-key="toppings"] .option-item').forEach(item => item.style.display = ''); // Show all
390
+ }
391
+
392
+
393
+ setTimeout(() => displayRecommendations(allPizzasData, true), 100);
394
+ if(recommendationSubtitle) recommendationSubtitle.textContent = `Showing all ${allPizzasData.length} available pizzas. Customize your search above!`;
395
+ });
396
+ }
397
+
398
+ function fetchRecommendations() {
399
+ if(loadingIndicator) loadingIndicator.classList.remove('hidden');
400
+ if(pizzaRecommendationsEl) pizzaRecommendationsEl.classList.add('hidden');
401
+ if(recommendationSubtitle) recommendationSubtitle.textContent = "Based on your preferences, we think you'll love these pizzas!";
402
+
403
+ const preferences = {
404
+ // Numerical/Range preferences
405
+ price_range: priceRange ? [199, parseInt(priceRange.value)] : null,
406
+ slices: slicesRange ? parseInt(slicesRange.value) : null,
407
+ rating: ratingRange ? parseFloat(ratingRange.value) : null,
408
+ prep_time: prepTimeRange ? parseInt(prepTimeRange.value) : null,
409
+ // Categorical (multi-select) preferences from selectedCategoricalFilters
410
+ // Keys here must match what backend expects (e.g. 'servingsize', not 'serving_size')
411
+ toppings: selectedCategoricalFilters['toppings'] || [],
412
+ servingsize: selectedCategoricalFilters['servingsize'] || [],
413
+ dietarycategory: selectedCategoricalFilters['dietarycategory'] || [],
414
+ spicelevel: selectedCategoricalFilters['spicelevel'] || [],
415
+ crusttype: selectedCategoricalFilters['crusttype'] || [],
416
+ populargroup: selectedCategoricalFilters['populargroup'] || [],
417
+ saucetype: selectedCategoricalFilters['saucetype'] || [],
418
+ cheeseamount: selectedCategoricalFilters['cheeseamount'] || [],
419
+ restaurantchain: selectedCategoricalFilters['restaurantchain'] || [],
420
+ seasonalavailability: selectedCategoricalFilters['seasonalavailability'] || [],
421
+ breadtype: selectedCategoricalFilters['breadtype'] || []
422
+ };
423
+
424
+ const finalPreferences = {};
425
+ for (const key in preferences) {
426
+ if (preferences[key] !== null) { // Allow empty arrays (for "Any" categorical)
427
+ if (Array.isArray(preferences[key])) { // For multi-selects including toppings
428
+ finalPreferences[key] = preferences[key];
429
+ } else { // For single value numerical/range
430
+ finalPreferences[key] = preferences[key];
431
+ }
432
+ }
433
+ }
434
+ // console.log("Sending preferences:", finalPreferences);
435
+
436
+ fetch('/recommend', {
437
+ method: 'POST',
438
+ headers: { 'Content-Type': 'application/json' },
439
+ body: JSON.stringify(finalPreferences),
440
+ })
441
+ .then(response => {
442
+ if (!response.ok) {
443
+ return response.json().then(errData => { throw { status: response.status, data: errData }; })
444
+ .catch(() => { throw { status: response.status, data: { error: "Server error, could not parse details." } }; });
445
+ }
446
+ return response.json();
447
+ })
448
+ .then(data => {
449
+ if(loadingIndicator) loadingIndicator.classList.add('hidden');
450
+ if(pizzaRecommendationsEl) pizzaRecommendationsEl.classList.remove('hidden');
451
+ displayRecommendations(data);
452
+ })
453
+ .catch(errorObj => {
454
+ console.error('Error fetching recommendations:', errorObj);
455
+ if(loadingIndicator) loadingIndicator.classList.add('hidden');
456
+ if(pizzaRecommendationsEl) pizzaRecommendationsEl.classList.remove('hidden');
457
+ let errorMsg = "We couldn't fetch recommendations. Please try again later.";
458
+ if (errorObj.data && errorObj.data.error) { errorMsg = errorObj.data.error; }
459
+ if(pizzaRecommendationsEl) pizzaRecommendationsEl.innerHTML = `
460
+ <div class="col-span-1 md:col-span-2 xl:col-span-3 text-center py-10">
461
+ <i class="fas fa-exclamation-triangle text-red-500 text-5xl mb-4"></i>
462
+ <h3 class="text-xl font-semibold mb-2">Oops! Something went wrong</h3>
463
+ <p class="text-gray-600">${errorMsg}</p>
464
+ </div>`;
465
+ });
466
+ }
467
+
468
+ function displayRecommendations(pizzas, isDefaultAll = false) {
469
+ if (!pizzaRecommendationsEl) return;
470
+ pizzaRecommendationsEl.innerHTML = '';
471
+ if (isDefaultAll) { allPizzasData = pizzas; } // Update global store if it's the initial full load
472
+
473
+ if (!pizzas || pizzas.length === 0) {
474
+ if(recommendationSubtitle) recommendationSubtitle.textContent = "No pizzas match your current criteria.";
475
+ pizzaRecommendationsEl.innerHTML = `
476
+ <div class="col-span-1 md:col-span-2 xl:col-span-3 text-center py-10">
477
+ <i class="fas fa-search-minus text-primary text-5xl mb-4"></i>
478
+ <h3 class="text-xl font-semibold mb-2">No Pizzas Found</h3>
479
+ <p class="text-gray-600">Try adjusting your filters for a wider search!</p>
480
+ </div>`;
481
+ return;
482
+ }
483
+ if(recommendationSubtitle && !isDefaultAll) recommendationSubtitle.textContent = `Found ${pizzas.length} pizza(s) matching your taste!`;
484
+ else if (recommendationSubtitle && isDefaultAll) recommendationSubtitle.textContent = `Showing all ${pizzas.length} available pizzas.`;
485
+
486
+ let html = '';
487
+ pizzas.forEach((pizza) => {
488
+ const p_rating = parseFloat(pizza.rating || 0);
489
+ let ratingStarsHTML = '';
490
+ for (let i = 0; i < 5; i++) {
491
+ if (i < Math.floor(p_rating)) ratingStarsHTML += '<i class="fas fa-star"></i>';
492
+ else if (i < p_rating) ratingStarsHTML += '<i class="fas fa-star-half-alt"></i>';
493
+ else ratingStarsHTML += '<i class="far fa-star"></i>';
494
+ }
495
+
496
+ const dietaryCategory = pizza.dietary_category;
497
+ let dietaryBadgeClass = 'badge-veg'; let dietaryText = dietaryCategory;
498
+ if (dietaryCategory === 'Non-Vegetarian') { dietaryBadgeClass = 'badge-non-veg'; dietaryText = 'Non-Veg'; }
499
+ else if (dietaryCategory === 'Vegan') { dietaryBadgeClass = 'badge-vegan'; }
500
+
501
+ const pizzaName = pizza.name || 'Unknown Pizza';
502
+ const price = pizza.price || 'N/A';
503
+ const ratingCount = pizza.rating_count || 0;
504
+ const spiceLevel = pizza.spice_level;
505
+ const description = pizza.description || 'No description available.';
506
+ const cheeseAmount = pizza.cheese_amount;
507
+ const slicesCount = pizza.slices || 'N/A';
508
+ const servingSize = pizza.serving_size;
509
+ const calories = pizza.calories;
510
+ const prepTime = pizza.prep_time;
511
+ const toppings = pizza.toppings;
512
+ const imageUrl = pizza.image_url || DEFAULT_IMAGE_URL_JS;
513
+
514
+ html += `
515
+ <div class="card" data-pizza-id="${pizza.id}">
516
+ <div class="card-image">
517
+ <img src="${imageUrl}" alt="${pizzaName}" onerror="this.onerror=null;this.src='${DEFAULT_IMAGE_URL_JS}';">
518
+ <div class="price-tag">₹${price}</div>
519
+ </div>
520
+ <div class="p-4">
521
+ <h3 class="card-title mb-2 truncate" title="${pizzaName}">${pizzaName}</h3>
522
+ <div class="flex items-center mb-3"><div class="rating mr-2">${ratingStarsHTML}</div><span class="text-xs text-gray-600">${p_rating.toFixed(1)} (${ratingCount})</span></div>
523
+ <div class="mb-3">
524
+ ${dietaryCategory ? `<span class="pizza-badge ${dietaryBadgeClass}">${dietaryText}</span>` : ''}
525
+ ${spiceLevel ? `<span class="pizza-badge badge-spice">${spiceLevel}</span>` : ''}
526
+ </div>
527
+ <p class="text-xs text-gray-600 mb-4 line-clamp-3 h-12">${description}</p>
528
+ <div class="text-xs text-gray-500 mb-3 space-y-1">
529
+ ${cheeseAmount ? `<div><i class="fas fa-cheese mr-2 w-4 text-center"></i>${cheeseAmount} cheese</div>` : ''}
530
+ <div><i class="fas fa-utensils mr-2 w-4 text-center"></i>${slicesCount} slices (${servingSize || 'N/A'})</div>
531
+ ${calories ? `<div><i class="fas fa-fire mr-2 w-4 text-center"></i>${calories} cal/slice</div>` : ''}
532
+ ${prepTime ? `<div><i class="fas fa-clock mr-2 w-4 text-center"></i>${prepTime} mins</div>` : ''}
533
+ </div>
534
+ ${toppings ? `<div class="border-t pt-3"><h4 class="text-xs font-semibold mb-1">Toppings:</h4><p class="text-xs text-gray-600 truncate">${toppings.replace(/;/g, ', ')}</p></div>` : ''}
535
+ </div>
536
+ </div>`;
537
+ });
538
+ pizzaRecommendationsEl.innerHTML = html;
539
+
540
+ pizzaRecommendationsEl.querySelectorAll('.card').forEach(card => {
541
+ card.addEventListener('click', function() {
542
+ const pizzaId = parseInt(this.dataset.pizzaId);
543
+ // Try finding in current displayed list first, then fallback to allPizzasData
544
+ const clickedPizzaData = pizzas.find(p => p.id === pizzaId) || allPizzasData.find(p => p.id === pizzaId);
545
+ if (clickedPizzaData) openPizzaModal(clickedPizzaData);
546
+ else console.error("Could not find pizza data for ID:", pizzaId, "in current recommendations or allPizzasData");
547
+ });
548
+ });
549
+ }
550
+
551
+ // --- Modal Functionality ---
552
+ const pizzaModal = document.getElementById('pizza-modal');
553
+ const modalCloseBtn = document.getElementById('modal-close-btn');
554
+
555
+ function openPizzaModal(pizza) {
556
+ if (!pizzaModal || !pizza) return;
557
+ document.getElementById('modal-pizza-name').textContent = pizza.name || 'N/A';
558
+ document.getElementById('modal-pizza-image').src = pizza.image_url || DEFAULT_IMAGE_URL_JS;
559
+ document.getElementById('modal-pizza-image').onerror = function() { this.onerror=null; this.src=DEFAULT_IMAGE_URL_JS; };
560
+ document.getElementById('modal-pizza-price').textContent = pizza.price || 'N/A';
561
+ const modalRating = parseFloat(pizza.rating || 0);
562
+ updateRatingStars(modalRating, document.getElementById('modal-pizza-rating-stars'), true);
563
+ document.getElementById('modal-pizza-rating-text').textContent = `(${modalRating.toFixed(1)} from ${pizza.rating_count || 0} ratings)`;
564
+ document.getElementById('modal-pizza-description').textContent = pizza.description || 'N/A';
565
+ document.getElementById('modal-pizza-toppings').textContent = (pizza.toppings || 'N/A').replace(/;/g, ', ');
566
+ document.getElementById('modal-pizza-slices').textContent = pizza.slices || 'N/A';
567
+ document.getElementById('modal-pizza-serving-size').textContent = pizza.serving_size || 'N/A';
568
+ document.getElementById('modal-pizza-dietary').textContent = pizza.dietary_category || 'N/A';
569
+ document.getElementById('modal-pizza-spice').textContent = pizza.spice_level || 'N/A';
570
+ document.getElementById('modal-pizza-crust-type').textContent = pizza.crust_type || 'N/A';
571
+ document.getElementById('modal-pizza-sauce').textContent = pizza.sauce_type || 'N/A';
572
+ document.getElementById('modal-pizza-cheese').textContent = pizza.cheese_amount || 'N/A';
573
+ document.getElementById('modal-pizza-calories').textContent = pizza.calories || 'N/A';
574
+ document.getElementById('modal-pizza-prep-time').textContent = pizza.prep_time || 'N/A';
575
+ document.getElementById('modal-pizza-restaurant').textContent = pizza.restaurant || 'N/A';
576
+ document.getElementById('modal-pizza-popular-group').textContent = pizza.popular_group || 'N/A';
577
+ document.getElementById('modal-pizza-seasonal').textContent = pizza.seasonal || 'N/A';
578
+ document.getElementById('modal-pizza-bread').textContent = pizza.bread_type || 'N/A';
579
+ document.getElementById('modal-pizza-allergens').textContent = (pizza.allergens || 'N/A').replace(/;/g, ', ');
580
+
581
+ pizzaModal.classList.remove('hidden');
582
+ setTimeout(() => { pizzaModal.classList.remove('opacity-0'); pizzaModal.querySelector('.transform').classList.remove('scale-95'); }, 10);
583
+ document.body.style.overflow = 'hidden';
584
+ }
585
+ if(modalCloseBtn) modalCloseBtn.addEventListener('click', closeModal);
586
+ if(pizzaModal) pizzaModal.addEventListener('click', function(e) { if (e.target === pizzaModal) closeModal(); });
587
+ function closeModal() {
588
+ if (!pizzaModal) return;
589
+ pizzaModal.classList.add('opacity-0');
590
+ pizzaModal.querySelector('.transform').classList.add('scale-95');
591
+ setTimeout(() => pizzaModal.classList.add('hidden'), 300);
592
+ document.body.style.overflow = 'auto';
593
+ }
594
+
595
+ // --- Initial Page Load ---
596
+ displayRecommendations(allPizzasData, true); // Display all default pizzas
597
+ if(recommendationSubtitle) recommendationSubtitle.textContent = `Showing all ${allPizzasData.length} available pizzas. Customize your search above!`;
598
+ // Initialize display text for all multi-selects
599
+ multiSelectDropdowns.forEach(dropdownEl => {
600
+ const filterKey = dropdownEl.dataset.filterKey;
601
+ updateSelectedTagsDisplay(dropdownEl, filterKey);
602
+ });
603
+ });
604
+ </script>
605
+ </body>
606
+ </html>