added
Browse files- .gitignore +66 -0
- Dockerfile +25 -0
- LICENSE +21 -0
- README.md +3 -3
- attachment/desh_travel.txt +13 -0
- attachment/ena.txt +14 -0
- attachment/green line.txt +13 -0
- attachment/hanif.txt +14 -0
- attachment/shyamoli.txt +13 -0
- attachment/soudia.txt +13 -0
- backend/data_loader.py +125 -0
- backend/database.py +285 -0
- backend/main.py +366 -0
- backend/models.py +15 -0
- backend/rag_pipeline.py +145 -0
- data.json +103 -0
- docker-compose.yml +16 -0
- dokerignore +13 -0
- requirements.txt +20 -0
- static/css/style.css +590 -0
- static/js/main.js +37 -0
- templates/assistant.html +124 -0
- templates/base.html +32 -0
- templates/bookings.html +105 -0
- templates/index.html +260 -0
- templates/providers.html +74 -0
- templates/routes.html +59 -0
.gitignore
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===== Python =====
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
*.pyc
|
| 7 |
+
*.pdb
|
| 8 |
+
*.egg
|
| 9 |
+
*.egg-info/
|
| 10 |
+
dist/
|
| 11 |
+
build/
|
| 12 |
+
eggs/
|
| 13 |
+
parts/
|
| 14 |
+
var/
|
| 15 |
+
sdist/
|
| 16 |
+
develop-eggs/
|
| 17 |
+
.installed.cfg
|
| 18 |
+
lib/
|
| 19 |
+
lib64/
|
| 20 |
+
|
| 21 |
+
# ===== Virtual Environment =====
|
| 22 |
+
venv/
|
| 23 |
+
env/
|
| 24 |
+
ENV/
|
| 25 |
+
.venv/
|
| 26 |
+
|
| 27 |
+
# ===== Environment Variables =====
|
| 28 |
+
.env
|
| 29 |
+
.env.*
|
| 30 |
+
!.env.example
|
| 31 |
+
|
| 32 |
+
# ===== Database =====
|
| 33 |
+
*.db
|
| 34 |
+
*.sqlite
|
| 35 |
+
*.sqlite3
|
| 36 |
+
|
| 37 |
+
# ===== Vector Store / ChromaDB =====
|
| 38 |
+
vectorstore/
|
| 39 |
+
chroma_db/
|
| 40 |
+
chunks.txt
|
| 41 |
+
|
| 42 |
+
# ===== Logs =====
|
| 43 |
+
*.log
|
| 44 |
+
logs/
|
| 45 |
+
|
| 46 |
+
# ===== OS Files =====
|
| 47 |
+
.DS_Store
|
| 48 |
+
Thumbs.db
|
| 49 |
+
desktop.ini
|
| 50 |
+
|
| 51 |
+
# ===== IDE =====
|
| 52 |
+
.vscode/
|
| 53 |
+
.idea/
|
| 54 |
+
*.suo
|
| 55 |
+
*.ntvs*
|
| 56 |
+
*.njsproj
|
| 57 |
+
*.sln
|
| 58 |
+
*.sw?
|
| 59 |
+
|
| 60 |
+
# ===== Docker =====
|
| 61 |
+
.dockerignore
|
| 62 |
+
|
| 63 |
+
# ===== Misc =====
|
| 64 |
+
*.bak
|
| 65 |
+
*.tmp
|
| 66 |
+
*.swp
|
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use official Python image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
build-essential \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Copy requirements first (for caching)
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
|
| 15 |
+
# Install Python dependencies
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Copy entire project
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
# Expose port
|
| 22 |
+
EXPOSE 8000
|
| 23 |
+
|
| 24 |
+
# Run the app
|
| 25 |
+
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Md Shoaib Shahriar Ibrahim
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
---
|
| 2 |
title: Bus Ticket Booking Application
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
|
|
|
| 1 |
---
|
| 2 |
title: Bus Ticket Booking Application
|
| 3 |
+
emoji: π’
|
| 4 |
+
colorFrom: pink
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
attachment/desh_travel.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Desh Travel Privacy Policy
|
| 3 |
+
|
| 4 |
+
Desh Travel is committed to protecting the privacy and personal data of our customers. We collect personal details such as names, phone numbers, travel history, and booking preferences to provide efficient booking and travel services. This information helps us improve service quality, offer relevant promotions, and ensure smooth operational management.
|
| 5 |
+
|
| 6 |
+
Official Address: 70A Progoti Shoroni, Lift 4, Rupayan Millennium Square, Dhaka 1212, Bangladesh
|
| 7 |
+
Contact Information: Email: info@deshtravelbd.com
|
| 8 |
+
Privacy Policy / Terms Link: https://www.deshtravelbd.com/termsandcondition?utm_source=chatgpt.com
|
| 9 |
+
|
| 10 |
+
All collected information is stored securely, with access limited to authorized personnel only. Data sharing with external entities occurs strictly when required for legal or operational needs. Security protocols include encryption, access restrictions, and regular audits.
|
| 11 |
+
|
| 12 |
+
By utilizing Desh Travel services, you consent to the policies outlined here. Updates to this privacy policy will be posted on our website and are effective immediately upon publication.
|
| 13 |
+
|
attachment/ena.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Ena Privacy Policy
|
| 3 |
+
|
| 4 |
+
Ena is committed to protecting the privacy and personal data of our customers. We collect personal details such as names, phone numbers, travel history, and booking preferences to provide efficient booking and travel services. This information helps us improve service quality, offer relevant promotions, and ensure smooth operational management.
|
| 5 |
+
|
| 6 |
+
Official Address: StarNet, Plot 97/A, 2nd Floor, Road-7, Sector-4, Uttara C/A, Dhaka-1230
|
| 7 |
+
Contact Information: Mohakhali Bus Stand: 01760-737650 (Non-AC) & 01619-737650 (AC)
|
| 8 |
+
Privacy Policy / Terms Link: See website for full Privacy Policy
|
| 9 |
+
|
| 10 |
+
All collected information is stored securely, with access limited to authorized personnel only. Data sharing with external entities occurs strictly when required for legal or operational needs. Security protocols include encryption, access restrictions, and regular audits.
|
| 11 |
+
|
| 12 |
+
By utilizing Ena services, you consent to the policies outlined here. Updates to this privacy policy will be posted on our website and are effective immediately upon publication.
|
| 13 |
+
|
| 14 |
+
|
attachment/green line.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Green Line Privacy Policy
|
| 3 |
+
|
| 4 |
+
Green Line is committed to protecting the privacy and personal data of our customers. We collect personal details such as names, phone numbers, travel history, and booking preferences to provide efficient booking and travel services. This information helps us improve service quality, offer relevant promotions, and ensure smooth operational management.
|
| 5 |
+
|
| 6 |
+
Official Address: 9/2, Outer Circular Road, Momen Bagh, Rajarbagh, Dhaka 1217
|
| 7 |
+
Contact Information: Tel: +88 02 8315380, Call Center Mobile: 09613316557
|
| 8 |
+
Privacy Policy / Terms Link: Refer to website for Privacy Policy
|
| 9 |
+
|
| 10 |
+
All collected information is stored securely, with access limited to authorized personnel only. Data sharing with external entities occurs strictly when required for legal or operational needs. Security protocols include encryption, access restrictions, and regular audits.
|
| 11 |
+
|
| 12 |
+
By utilizing Green Line services, you consent to the policies outlined here. Updates to this privacy policy will be posted on our website and are effective immediately upon publication.
|
| 13 |
+
|
attachment/hanif.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Hanif Privacy Policy
|
| 3 |
+
|
| 4 |
+
Hanif is committed to protecting the privacy and personal data of our customers. We collect personal details such as names, phone numbers, travel history, and booking preferences to provide efficient booking and travel services. This information helps us improve service quality, offer relevant promotions, and ensure smooth operational management.
|
| 5 |
+
|
| 6 |
+
Official Address: Gabtoli / Mirpur region, Dhaka
|
| 7 |
+
Contact Information: Customer Support: 16460, Counter: 01713-049540
|
| 8 |
+
Privacy Policy / Terms Link: https://hanifenterprisebd.com/privacy-policy?utm_source=chatgpt.com
|
| 9 |
+
|
| 10 |
+
All collected information is stored securely, with access limited to authorized personnel only. Data sharing with external entities occurs strictly when required for legal or operational needs. Security protocols include encryption, access restrictions, and regular audits.
|
| 11 |
+
|
| 12 |
+
By utilizing Hanif services, you consent to the policies outlined here. Updates to this privacy policy will be posted on our website and are effective immediately upon publication.
|
| 13 |
+
|
| 14 |
+
|
attachment/shyamoli.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Shyamoli Privacy Policy
|
| 3 |
+
|
| 4 |
+
Shyamoli is committed to protecting the privacy and personal data of our customers. We collect personal details such as names, phone numbers, travel history, and booking preferences to provide efficient booking and travel services. This information helps us improve service quality, offer relevant promotions, and ensure smooth operational management.
|
| 5 |
+
|
| 6 |
+
Official Address: 27/ka, Pisciculture Housing Society, Shyamoli, Mohammadpur, Dhaka-1207
|
| 7 |
+
Contact Information: Reservations/Complaints: 01908899544, 01908899522, 01908899499
|
| 8 |
+
Privacy Policy / Terms Link: Full Privacy Policy available on website
|
| 9 |
+
|
| 10 |
+
All collected information is stored securely, with access limited to authorized personnel only. Data sharing with external entities occurs strictly when required for legal or operational needs. Security protocols include encryption, access restrictions, and regular audits.
|
| 11 |
+
|
| 12 |
+
By utilizing Shyamoli services, you consent to the policies outlined here. Updates to this privacy policy will be posted on our website and are effective immediately upon publication.
|
| 13 |
+
|
attachment/soudia.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Soudia Privacy Policy
|
| 3 |
+
|
| 4 |
+
Soudia is committed to protecting the privacy and personal data of our customers. We collect personal details such as names, phone numbers, travel history, and booking preferences to provide efficient booking and travel services. This information helps us improve service quality, offer relevant promotions, and ensure smooth operational management.
|
| 5 |
+
|
| 6 |
+
Official Address: Panthapath, Dhaka 1205
|
| 7 |
+
Contact Information: Dhaka Panthapath Counter: 01919-654926
|
| 8 |
+
Privacy Policy / Terms Link: See website or counter for Privacy Policy
|
| 9 |
+
|
| 10 |
+
All collected information is stored securely, with access limited to authorized personnel only. Data sharing with external entities occurs strictly when required for legal or operational needs. Security protocols include encryption, access restrictions, and regular audits.
|
| 11 |
+
|
| 12 |
+
By utilizing Soudia services, you consent to the policies outlined here. Updates to this privacy policy will be posted on our website and are effective immediately upon publication.
|
| 13 |
+
|
backend/data_loader.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/data_loader.py
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
| 5 |
+
|
| 6 |
+
DATASET_FILE = Path("data.json")
|
| 7 |
+
POLICY_FOLDER = Path("attachment")
|
| 8 |
+
|
| 9 |
+
# ---------------------------
|
| 10 |
+
# Load main dataset
|
| 11 |
+
# ---------------------------
|
| 12 |
+
with open(DATASET_FILE, "r", encoding="utf-8") as f:
|
| 13 |
+
data = json.load(f)
|
| 14 |
+
|
| 15 |
+
districts = data["districts"]
|
| 16 |
+
providers = data["bus_providers"]
|
| 17 |
+
|
| 18 |
+
# ---------------------------
|
| 19 |
+
# Chunk storage container
|
| 20 |
+
# ---------------------------
|
| 21 |
+
all_chunks = []
|
| 22 |
+
|
| 23 |
+
# ---------------------------
|
| 24 |
+
# District & Dropping Point Chunks
|
| 25 |
+
# ---------------------------
|
| 26 |
+
for district in districts:
|
| 27 |
+
# District block summary
|
| 28 |
+
text = f"District: {district['name']}\nDropping points:\n"
|
| 29 |
+
|
| 30 |
+
for dp in district["dropping_points"]:
|
| 31 |
+
text += f"β’ {dp['name']} β {dp['price']} Taka\n"
|
| 32 |
+
|
| 33 |
+
all_chunks.append({
|
| 34 |
+
"content": text,
|
| 35 |
+
"metadata": {
|
| 36 |
+
"type": "district",
|
| 37 |
+
"district": district["name"]
|
| 38 |
+
}
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
# Individual dropping points
|
| 42 |
+
for dp in district["dropping_points"]:
|
| 43 |
+
dp_text = (
|
| 44 |
+
f"Dropping point: {dp['name']} in {district['name']}.\n"
|
| 45 |
+
f"Fare: {dp['price']} Taka."
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
all_chunks.append({
|
| 49 |
+
"content": dp_text,
|
| 50 |
+
"metadata": {
|
| 51 |
+
"type": "dropping_point",
|
| 52 |
+
"district": district["name"],
|
| 53 |
+
"point": dp["name"],
|
| 54 |
+
"price": dp["price"]
|
| 55 |
+
}
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
# ---------------------------
|
| 59 |
+
# Provider Chunks (Desh, Hanif, Ena, etc.)
|
| 60 |
+
# ---------------------------
|
| 61 |
+
for provider in providers:
|
| 62 |
+
provider_text = (
|
| 63 |
+
f"Bus Provider: {provider['name']}\n"
|
| 64 |
+
f"Coverage Districts: {', '.join(provider['coverage_districts'])}"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
all_chunks.append({
|
| 68 |
+
"content": provider_text,
|
| 69 |
+
"metadata": {
|
| 70 |
+
"type": "provider",
|
| 71 |
+
"provider": provider["name"].lower(),
|
| 72 |
+
"districts": provider["coverage_districts"]
|
| 73 |
+
}
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
# ---------------------------
|
| 77 |
+
# Policy Chunks (Hanif.txt, Ena.txt, etc.)
|
| 78 |
+
# ---------------------------
|
| 79 |
+
text_splitter = RecursiveCharacterTextSplitter(
|
| 80 |
+
chunk_size=500,
|
| 81 |
+
chunk_overlap=150
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
policy_files = list(POLICY_FOLDER.glob("*.txt"))
|
| 85 |
+
|
| 86 |
+
for txt_file in policy_files:
|
| 87 |
+
provider_name = txt_file.stem.lower()
|
| 88 |
+
|
| 89 |
+
with open(txt_file, "r", encoding="utf-8") as f:
|
| 90 |
+
policy_text = f.read().strip()
|
| 91 |
+
|
| 92 |
+
wrapped_text = f"Policy of {provider_name} bus:\n\n{policy_text}"
|
| 93 |
+
|
| 94 |
+
chunks = text_splitter.split_text(wrapped_text)
|
| 95 |
+
|
| 96 |
+
for i, chunk in enumerate(chunks):
|
| 97 |
+
all_chunks.append({
|
| 98 |
+
"content": chunk,
|
| 99 |
+
"metadata": {
|
| 100 |
+
"type": "policy",
|
| 101 |
+
"provider": provider_name,
|
| 102 |
+
"chunk_index": i
|
| 103 |
+
}
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
# --------------- SUMMARY ----------------
|
| 107 |
+
print("\n======================================")
|
| 108 |
+
print("Total Chunks Created:", len(all_chunks))
|
| 109 |
+
print("======================================\n")
|
| 110 |
+
|
| 111 |
+
OUTPUT_FILE = "chunks.txt"
|
| 112 |
+
|
| 113 |
+
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
| 114 |
+
for index, chunk in enumerate(all_chunks):
|
| 115 |
+
f.write("============================================================\n")
|
| 116 |
+
f.write(f"CHUNK #{index}\n")
|
| 117 |
+
f.write("------------------------------------------------------------\n")
|
| 118 |
+
f.write("CONTENT:\n")
|
| 119 |
+
f.write(chunk["content"] + "\n\n")
|
| 120 |
+
f.write("METADATA:\n")
|
| 121 |
+
for k, v in chunk["metadata"].items():
|
| 122 |
+
f.write(f" {k}: {v}\n")
|
| 123 |
+
f.write("============================================================\n\n")
|
| 124 |
+
|
| 125 |
+
print(f"Dumped {len(all_chunks)} chunks to chunks.txt")
|
backend/database.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/database.py
|
| 2 |
+
import sqlite3
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import List, Optional, Dict
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
DATABASE_FILE = Path("bus_bookings.db")
|
| 9 |
+
|
| 10 |
+
def get_db_connection():
|
| 11 |
+
"""Create database connection"""
|
| 12 |
+
conn = sqlite3.connect(DATABASE_FILE)
|
| 13 |
+
conn.row_factory = sqlite3.Row # Return rows as dictionaries
|
| 14 |
+
return conn
|
| 15 |
+
|
| 16 |
+
def init_database():
|
| 17 |
+
"""Initialize database tables"""
|
| 18 |
+
conn = get_db_connection()
|
| 19 |
+
cursor = conn.cursor()
|
| 20 |
+
|
| 21 |
+
# Bookings table
|
| 22 |
+
cursor.execute("""
|
| 23 |
+
CREATE TABLE IF NOT EXISTS bookings (
|
| 24 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 25 |
+
booking_id TEXT UNIQUE NOT NULL,
|
| 26 |
+
name TEXT NOT NULL,
|
| 27 |
+
phone TEXT NOT NULL,
|
| 28 |
+
bus_provider TEXT NOT NULL,
|
| 29 |
+
from_district TEXT NOT NULL,
|
| 30 |
+
to_district TEXT NOT NULL,
|
| 31 |
+
dropping_point TEXT NOT NULL,
|
| 32 |
+
travel_date TEXT NOT NULL,
|
| 33 |
+
num_passengers INTEGER NOT NULL,
|
| 34 |
+
fare INTEGER NOT NULL,
|
| 35 |
+
total_amount INTEGER NOT NULL,
|
| 36 |
+
booking_date TEXT NOT NULL,
|
| 37 |
+
status TEXT NOT NULL DEFAULT 'active',
|
| 38 |
+
cancelled_date TEXT,
|
| 39 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 40 |
+
)
|
| 41 |
+
""")
|
| 42 |
+
|
| 43 |
+
# Chat history table
|
| 44 |
+
cursor.execute("""
|
| 45 |
+
CREATE TABLE IF NOT EXISTS chat_history (
|
| 46 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 47 |
+
session_id TEXT NOT NULL,
|
| 48 |
+
phone TEXT,
|
| 49 |
+
role TEXT NOT NULL,
|
| 50 |
+
message TEXT NOT NULL,
|
| 51 |
+
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 52 |
+
)
|
| 53 |
+
""")
|
| 54 |
+
|
| 55 |
+
# Deleted bookings history table
|
| 56 |
+
cursor.execute("""
|
| 57 |
+
CREATE TABLE IF NOT EXISTS deleted_bookings (
|
| 58 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 59 |
+
booking_id TEXT NOT NULL,
|
| 60 |
+
booking_data TEXT NOT NULL,
|
| 61 |
+
deleted_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 62 |
+
deleted_by_phone TEXT
|
| 63 |
+
)
|
| 64 |
+
""")
|
| 65 |
+
|
| 66 |
+
conn.commit()
|
| 67 |
+
conn.close()
|
| 68 |
+
print("β
Database initialized")
|
| 69 |
+
|
| 70 |
+
# Initialize database on import
|
| 71 |
+
init_database()
|
| 72 |
+
|
| 73 |
+
# ==================== Booking Operations ====================
|
| 74 |
+
|
| 75 |
+
def create_booking(booking_data: dict) -> dict:
|
| 76 |
+
"""Create a new booking"""
|
| 77 |
+
conn = get_db_connection()
|
| 78 |
+
cursor = conn.cursor()
|
| 79 |
+
|
| 80 |
+
cursor.execute("""
|
| 81 |
+
INSERT INTO bookings (
|
| 82 |
+
booking_id, name, phone, bus_provider, from_district, to_district,
|
| 83 |
+
dropping_point, travel_date, num_passengers, fare, total_amount, booking_date, status
|
| 84 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 85 |
+
""", (
|
| 86 |
+
booking_data['booking_id'],
|
| 87 |
+
booking_data['name'],
|
| 88 |
+
booking_data['phone'],
|
| 89 |
+
booking_data['bus_provider'],
|
| 90 |
+
booking_data['from_district'],
|
| 91 |
+
booking_data['to_district'],
|
| 92 |
+
booking_data['dropping_point'],
|
| 93 |
+
booking_data['travel_date'],
|
| 94 |
+
booking_data['num_passengers'],
|
| 95 |
+
booking_data['fare'],
|
| 96 |
+
booking_data['total_amount'],
|
| 97 |
+
booking_data['booking_date'],
|
| 98 |
+
'active'
|
| 99 |
+
))
|
| 100 |
+
|
| 101 |
+
conn.commit()
|
| 102 |
+
conn.close()
|
| 103 |
+
|
| 104 |
+
return booking_data
|
| 105 |
+
|
| 106 |
+
def get_all_bookings() -> List[dict]:
|
| 107 |
+
"""Get all bookings"""
|
| 108 |
+
conn = get_db_connection()
|
| 109 |
+
cursor = conn.cursor()
|
| 110 |
+
|
| 111 |
+
cursor.execute("SELECT * FROM bookings ORDER BY created_at DESC")
|
| 112 |
+
bookings = [dict(row) for row in cursor.fetchall()]
|
| 113 |
+
|
| 114 |
+
conn.close()
|
| 115 |
+
return bookings
|
| 116 |
+
|
| 117 |
+
def get_bookings_by_phone(phone: str) -> List[dict]:
|
| 118 |
+
"""Get bookings by phone number"""
|
| 119 |
+
conn = get_db_connection()
|
| 120 |
+
cursor = conn.cursor()
|
| 121 |
+
|
| 122 |
+
cursor.execute("SELECT * FROM bookings WHERE phone = ? ORDER BY created_at DESC", (phone,))
|
| 123 |
+
bookings = [dict(row) for row in cursor.fetchall()]
|
| 124 |
+
|
| 125 |
+
conn.close()
|
| 126 |
+
return bookings
|
| 127 |
+
|
| 128 |
+
def get_booking_by_id(booking_id: str) -> Optional[dict]:
|
| 129 |
+
"""Get a specific booking"""
|
| 130 |
+
conn = get_db_connection()
|
| 131 |
+
cursor = conn.cursor()
|
| 132 |
+
|
| 133 |
+
cursor.execute("SELECT * FROM bookings WHERE booking_id = ?", (booking_id,))
|
| 134 |
+
row = cursor.fetchone()
|
| 135 |
+
|
| 136 |
+
conn.close()
|
| 137 |
+
return dict(row) if row else None
|
| 138 |
+
|
| 139 |
+
def delete_booking_permanently(booking_id: str) -> bool:
|
| 140 |
+
"""Permanently delete a booking"""
|
| 141 |
+
conn = get_db_connection()
|
| 142 |
+
cursor = conn.cursor()
|
| 143 |
+
|
| 144 |
+
# Get booking data first
|
| 145 |
+
cursor.execute("SELECT * FROM bookings WHERE booking_id = ?", (booking_id,))
|
| 146 |
+
booking = cursor.fetchone()
|
| 147 |
+
|
| 148 |
+
if not booking:
|
| 149 |
+
conn.close()
|
| 150 |
+
return False
|
| 151 |
+
|
| 152 |
+
# Save to deleted bookings history
|
| 153 |
+
booking_data = dict(booking)
|
| 154 |
+
cursor.execute("""
|
| 155 |
+
INSERT INTO deleted_bookings (booking_id, booking_data, deleted_by_phone)
|
| 156 |
+
VALUES (?, ?, ?)
|
| 157 |
+
""", (booking_id, json.dumps(booking_data), booking_data.get('phone')))
|
| 158 |
+
|
| 159 |
+
# Delete from bookings table
|
| 160 |
+
cursor.execute("DELETE FROM bookings WHERE booking_id = ?", (booking_id,))
|
| 161 |
+
|
| 162 |
+
conn.commit()
|
| 163 |
+
conn.close()
|
| 164 |
+
|
| 165 |
+
return True
|
| 166 |
+
|
| 167 |
+
def cancel_booking(booking_id: str) -> bool:
|
| 168 |
+
"""Mark booking as cancelled (soft delete)"""
|
| 169 |
+
conn = get_db_connection()
|
| 170 |
+
cursor = conn.cursor()
|
| 171 |
+
|
| 172 |
+
cursor.execute("""
|
| 173 |
+
UPDATE bookings
|
| 174 |
+
SET status = 'cancelled', cancelled_date = ?
|
| 175 |
+
WHERE booking_id = ? AND status = 'active'
|
| 176 |
+
""", (datetime.now().isoformat(), booking_id))
|
| 177 |
+
|
| 178 |
+
rows_affected = cursor.rowcount
|
| 179 |
+
conn.commit()
|
| 180 |
+
conn.close()
|
| 181 |
+
|
| 182 |
+
return rows_affected > 0
|
| 183 |
+
|
| 184 |
+
def generate_booking_id() -> str:
|
| 185 |
+
"""Generate unique booking ID"""
|
| 186 |
+
conn = get_db_connection()
|
| 187 |
+
cursor = conn.cursor()
|
| 188 |
+
|
| 189 |
+
cursor.execute("SELECT COUNT(*) as count FROM bookings")
|
| 190 |
+
count = cursor.fetchone()['count']
|
| 191 |
+
|
| 192 |
+
conn.close()
|
| 193 |
+
return f"BK{count + 1:05d}"
|
| 194 |
+
|
| 195 |
+
# ==================== Chat History Operations ====================
|
| 196 |
+
|
| 197 |
+
def save_chat_message(session_id: str, role: str, message: str, phone: str = None):
|
| 198 |
+
"""Save a chat message"""
|
| 199 |
+
conn = get_db_connection()
|
| 200 |
+
cursor = conn.cursor()
|
| 201 |
+
|
| 202 |
+
cursor.execute("""
|
| 203 |
+
INSERT INTO chat_history (session_id, phone, role, message)
|
| 204 |
+
VALUES (?, ?, ?, ?)
|
| 205 |
+
""", (session_id, phone, role, message))
|
| 206 |
+
|
| 207 |
+
conn.commit()
|
| 208 |
+
conn.close()
|
| 209 |
+
|
| 210 |
+
def get_chat_history(session_id: str, limit: int = 10) -> List[dict]:
|
| 211 |
+
"""Get chat history for a session"""
|
| 212 |
+
conn = get_db_connection()
|
| 213 |
+
cursor = conn.cursor()
|
| 214 |
+
|
| 215 |
+
cursor.execute("""
|
| 216 |
+
SELECT role, message, timestamp
|
| 217 |
+
FROM chat_history
|
| 218 |
+
WHERE session_id = ?
|
| 219 |
+
ORDER BY timestamp DESC
|
| 220 |
+
LIMIT ?
|
| 221 |
+
""", (session_id, limit))
|
| 222 |
+
|
| 223 |
+
history = [dict(row) for row in cursor.fetchall()]
|
| 224 |
+
history.reverse() # Oldest first
|
| 225 |
+
|
| 226 |
+
conn.close()
|
| 227 |
+
return history
|
| 228 |
+
|
| 229 |
+
def clear_chat_history(session_id: str):
|
| 230 |
+
"""Clear chat history for a session"""
|
| 231 |
+
conn = get_db_connection()
|
| 232 |
+
cursor = conn.cursor()
|
| 233 |
+
|
| 234 |
+
cursor.execute("DELETE FROM chat_history WHERE session_id = ?", (session_id,))
|
| 235 |
+
|
| 236 |
+
conn.commit()
|
| 237 |
+
conn.close()
|
| 238 |
+
|
| 239 |
+
# ==================== Statistics ====================
|
| 240 |
+
|
| 241 |
+
def get_booking_statistics() -> dict:
|
| 242 |
+
"""Get booking statistics"""
|
| 243 |
+
conn = get_db_connection()
|
| 244 |
+
cursor = conn.cursor()
|
| 245 |
+
|
| 246 |
+
# Total bookings
|
| 247 |
+
cursor.execute("SELECT COUNT(*) as count FROM bookings")
|
| 248 |
+
total = cursor.fetchone()['count']
|
| 249 |
+
|
| 250 |
+
# Active bookings
|
| 251 |
+
cursor.execute("SELECT COUNT(*) as count FROM bookings WHERE status = 'active'")
|
| 252 |
+
active = cursor.fetchone()['count']
|
| 253 |
+
|
| 254 |
+
# Cancelled bookings
|
| 255 |
+
cursor.execute("SELECT COUNT(*) as count FROM bookings WHERE status = 'cancelled'")
|
| 256 |
+
cancelled = cursor.fetchone()['count']
|
| 257 |
+
|
| 258 |
+
# Total revenue (active only)
|
| 259 |
+
cursor.execute("SELECT SUM(total_amount) as revenue FROM bookings WHERE status = 'active'")
|
| 260 |
+
revenue = cursor.fetchone()['revenue'] or 0
|
| 261 |
+
|
| 262 |
+
# Provider statistics
|
| 263 |
+
cursor.execute("""
|
| 264 |
+
SELECT bus_provider, COUNT(*) as count, SUM(total_amount) as revenue
|
| 265 |
+
FROM bookings
|
| 266 |
+
WHERE status = 'active'
|
| 267 |
+
GROUP BY bus_provider
|
| 268 |
+
""")
|
| 269 |
+
|
| 270 |
+
provider_stats = {}
|
| 271 |
+
for row in cursor.fetchall():
|
| 272 |
+
provider_stats[row['bus_provider']] = {
|
| 273 |
+
'count': row['count'],
|
| 274 |
+
'revenue': row['revenue']
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
conn.close()
|
| 278 |
+
|
| 279 |
+
return {
|
| 280 |
+
'total_bookings': total,
|
| 281 |
+
'active_bookings': active,
|
| 282 |
+
'cancelled_bookings': cancelled,
|
| 283 |
+
'total_revenue': revenue,
|
| 284 |
+
'provider_statistics': provider_stats
|
| 285 |
+
}
|
backend/main.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/main.py
|
| 2 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from fastapi.templating import Jinja2Templates
|
| 5 |
+
from fastapi.staticfiles import StaticFiles
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
from typing import List, Optional
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
import json
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import uuid
|
| 12 |
+
|
| 13 |
+
from .database import (
|
| 14 |
+
create_booking, get_all_bookings, get_bookings_by_phone,
|
| 15 |
+
get_booking_by_id, cancel_booking, delete_booking_permanently,
|
| 16 |
+
generate_booking_id, get_booking_statistics, save_chat_message, get_chat_history
|
| 17 |
+
)
|
| 18 |
+
from .rag_pipeline import get_answer, get_answer_with_sources
|
| 19 |
+
|
| 20 |
+
app = FastAPI(title="Bus Ticket Booking System")
|
| 21 |
+
|
| 22 |
+
# Static files & templates
|
| 23 |
+
BASE_DIR = Path(__file__).parent.parent
|
| 24 |
+
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
| 25 |
+
templates = Jinja2Templates(directory=BASE_DIR / "templates")
|
| 26 |
+
|
| 27 |
+
# CORS middleware
|
| 28 |
+
app.add_middleware(
|
| 29 |
+
CORSMiddleware,
|
| 30 |
+
allow_origins=["*"],
|
| 31 |
+
allow_credentials=True,
|
| 32 |
+
allow_methods=["*"],
|
| 33 |
+
allow_headers=["*"],
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# Load bus data
|
| 37 |
+
DATA_FILE = BASE_DIR / "data.json"
|
| 38 |
+
with open(DATA_FILE, "r", encoding="utf-8") as f:
|
| 39 |
+
bus_data = json.load(f)
|
| 40 |
+
|
| 41 |
+
# ==================== Models ====================
|
| 42 |
+
|
| 43 |
+
class BookingCreate(BaseModel):
|
| 44 |
+
name: str = Field(..., min_length=2, max_length=100)
|
| 45 |
+
phone: str = Field(..., min_length=11, max_length=15)
|
| 46 |
+
bus_provider: str
|
| 47 |
+
from_district: str
|
| 48 |
+
to_district: str
|
| 49 |
+
dropping_point: str
|
| 50 |
+
travel_date: str
|
| 51 |
+
num_passengers: int = Field(default=1, ge=1, le=10)
|
| 52 |
+
|
| 53 |
+
class BookingResponse(BaseModel):
|
| 54 |
+
booking_id: str
|
| 55 |
+
name: str
|
| 56 |
+
phone: str
|
| 57 |
+
bus_provider: str
|
| 58 |
+
from_district: str
|
| 59 |
+
to_district: str
|
| 60 |
+
dropping_point: str
|
| 61 |
+
travel_date: str
|
| 62 |
+
num_passengers: int
|
| 63 |
+
fare: int
|
| 64 |
+
total_amount: int
|
| 65 |
+
booking_date: str
|
| 66 |
+
status: str
|
| 67 |
+
|
| 68 |
+
class QueryRequest(BaseModel):
|
| 69 |
+
query: str
|
| 70 |
+
phone: Optional[str] = None
|
| 71 |
+
session_id: Optional[str] = None
|
| 72 |
+
|
| 73 |
+
# ==================== Helper Functions ====================
|
| 74 |
+
|
| 75 |
+
def get_fare(district: str, dropping_point: str) -> int:
|
| 76 |
+
for dist in bus_data["districts"]:
|
| 77 |
+
if dist["name"].lower() == district.lower():
|
| 78 |
+
for dp in dist["dropping_points"]:
|
| 79 |
+
if dp["name"].lower() == dropping_point.lower():
|
| 80 |
+
return dp["price"]
|
| 81 |
+
return 0
|
| 82 |
+
|
| 83 |
+
def validate_route(provider: str, from_district: str, to_district: str) -> bool:
|
| 84 |
+
for p in bus_data["bus_providers"]:
|
| 85 |
+
if p["name"].lower() == provider.lower():
|
| 86 |
+
coverage_lower = [d.lower() for d in p["coverage_districts"]]
|
| 87 |
+
return from_district.lower() in coverage_lower and to_district.lower() in coverage_lower
|
| 88 |
+
return False
|
| 89 |
+
|
| 90 |
+
def get_available_providers(from_district: str, to_district: str) -> List[str]:
|
| 91 |
+
available = []
|
| 92 |
+
for provider in bus_data["bus_providers"]:
|
| 93 |
+
coverage_lower = [d.lower() for d in provider["coverage_districts"]]
|
| 94 |
+
if from_district.lower() in coverage_lower and to_district.lower() in coverage_lower:
|
| 95 |
+
available.append(provider["name"])
|
| 96 |
+
return available
|
| 97 |
+
|
| 98 |
+
def get_dropping_points_by_district(district: str):
|
| 99 |
+
for d in bus_data["districts"]:
|
| 100 |
+
if d["name"].lower() == district.lower():
|
| 101 |
+
return [{"name": dp["name"], "price": dp["price"]} for dp in d["dropping_points"]]
|
| 102 |
+
return []
|
| 103 |
+
|
| 104 |
+
# ==================== Session Storage ====================
|
| 105 |
+
sessions = {}
|
| 106 |
+
|
| 107 |
+
# ==================== Page Routes (HTML) ====================
|
| 108 |
+
|
| 109 |
+
@app.get("/")
|
| 110 |
+
def index(request: Request):
|
| 111 |
+
return templates.TemplateResponse("index.html", {
|
| 112 |
+
"request": request,
|
| 113 |
+
"active": "book",
|
| 114 |
+
"providers": bus_data["bus_providers"],
|
| 115 |
+
"districts": bus_data["districts"]
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
@app.get("/bookings-page")
|
| 119 |
+
def bookings_page(request: Request):
|
| 120 |
+
return templates.TemplateResponse("bookings.html", {
|
| 121 |
+
"request": request,
|
| 122 |
+
"active": "bookings"
|
| 123 |
+
})
|
| 124 |
+
|
| 125 |
+
@app.get("/providers-page")
|
| 126 |
+
def providers_page(request: Request):
|
| 127 |
+
return templates.TemplateResponse("providers.html", {
|
| 128 |
+
"request": request,
|
| 129 |
+
"active": "providers",
|
| 130 |
+
"providers": bus_data["bus_providers"]
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
@app.get("/routes-page")
|
| 134 |
+
def routes_page(request: Request):
|
| 135 |
+
return templates.TemplateResponse("routes.html", {
|
| 136 |
+
"request": request,
|
| 137 |
+
"active": "routes",
|
| 138 |
+
"districts": bus_data["districts"]
|
| 139 |
+
})
|
| 140 |
+
|
| 141 |
+
@app.get("/assistant-page")
|
| 142 |
+
def assistant_page(request: Request):
|
| 143 |
+
return templates.TemplateResponse("assistant.html", {
|
| 144 |
+
"request": request,
|
| 145 |
+
"active": "assistant"
|
| 146 |
+
})
|
| 147 |
+
|
| 148 |
+
# ==================== API Endpoints ====================
|
| 149 |
+
|
| 150 |
+
@app.get("/districts")
|
| 151 |
+
def get_districts():
|
| 152 |
+
return {"districts": bus_data["districts"]}
|
| 153 |
+
|
| 154 |
+
@app.get("/providers")
|
| 155 |
+
def get_providers():
|
| 156 |
+
return {"providers": bus_data["bus_providers"]}
|
| 157 |
+
|
| 158 |
+
@app.get("/providers/{provider_name}/policy")
|
| 159 |
+
def get_provider_policy(provider_name: str):
|
| 160 |
+
provider_map = {
|
| 161 |
+
"desh travel": "desh_travel.txt",
|
| 162 |
+
"ena": "ena.txt",
|
| 163 |
+
"green line": "green line.txt",
|
| 164 |
+
"greenline": "green line.txt",
|
| 165 |
+
"hanif": "hanif.txt",
|
| 166 |
+
"shyamoli": "shyamoli.txt",
|
| 167 |
+
"soudia": "soudia.txt"
|
| 168 |
+
}
|
| 169 |
+
normalized = provider_name.lower().strip()
|
| 170 |
+
if normalized not in provider_map:
|
| 171 |
+
raise HTTPException(status_code=404, detail="Policy not available for this provider")
|
| 172 |
+
file_name = provider_map[normalized]
|
| 173 |
+
file_path = BASE_DIR / "attachment" / file_name
|
| 174 |
+
try:
|
| 175 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 176 |
+
content = f.read()
|
| 177 |
+
except FileNotFoundError:
|
| 178 |
+
raise HTTPException(status_code=404, detail="Policy file not found")
|
| 179 |
+
return {"provider": provider_name, "policy": content}
|
| 180 |
+
|
| 181 |
+
@app.get("/available-providers")
|
| 182 |
+
def available_providers(from_district: str, to_district: str):
|
| 183 |
+
providers = get_available_providers(from_district, to_district)
|
| 184 |
+
if not providers:
|
| 185 |
+
return {"message": f"No providers between {from_district} and {to_district}", "providers": []}
|
| 186 |
+
return {"providers": providers}
|
| 187 |
+
|
| 188 |
+
@app.get("/dropping-points/{district}")
|
| 189 |
+
def dropping_points(district: str):
|
| 190 |
+
points = get_dropping_points_by_district(district)
|
| 191 |
+
if not points:
|
| 192 |
+
return {"message": f"No dropping points found for {district}", "dropping_points": []}
|
| 193 |
+
return {"dropping_points": points}
|
| 194 |
+
|
| 195 |
+
@app.post("/bookings", response_model=BookingResponse)
|
| 196 |
+
def create_booking_endpoint(booking: BookingCreate):
|
| 197 |
+
provider_exists = any(p["name"].lower() == booking.bus_provider.lower() for p in bus_data["bus_providers"])
|
| 198 |
+
if not provider_exists:
|
| 199 |
+
raise HTTPException(status_code=400, detail=f"Bus provider '{booking.bus_provider}' not found")
|
| 200 |
+
if not validate_route(booking.bus_provider, booking.from_district, booking.to_district):
|
| 201 |
+
raise HTTPException(status_code=400, detail=f"{booking.bus_provider} does not operate on this route")
|
| 202 |
+
fare = get_fare(booking.to_district, booking.dropping_point)
|
| 203 |
+
if fare == 0:
|
| 204 |
+
raise HTTPException(status_code=400, detail=f"Dropping point '{booking.dropping_point}' not found in {booking.to_district}")
|
| 205 |
+
new_booking_data = {
|
| 206 |
+
"booking_id": generate_booking_id(),
|
| 207 |
+
"name": booking.name,
|
| 208 |
+
"phone": booking.phone.strip(),
|
| 209 |
+
"bus_provider": booking.bus_provider,
|
| 210 |
+
"from_district": booking.from_district,
|
| 211 |
+
"to_district": booking.to_district,
|
| 212 |
+
"dropping_point": booking.dropping_point,
|
| 213 |
+
"travel_date": booking.travel_date,
|
| 214 |
+
"num_passengers": booking.num_passengers,
|
| 215 |
+
"fare": fare,
|
| 216 |
+
"total_amount": fare * booking.num_passengers,
|
| 217 |
+
"booking_date": datetime.now().isoformat(),
|
| 218 |
+
"status": "active"
|
| 219 |
+
}
|
| 220 |
+
saved_booking = create_booking(new_booking_data)
|
| 221 |
+
return saved_booking
|
| 222 |
+
|
| 223 |
+
@app.get("/bookings")
|
| 224 |
+
def list_all_bookings():
|
| 225 |
+
return {"bookings": get_all_bookings()}
|
| 226 |
+
|
| 227 |
+
@app.get("/bookings/phone/{phone}")
|
| 228 |
+
def bookings_by_phone(phone: str):
|
| 229 |
+
bookings = get_bookings_by_phone(phone.strip())
|
| 230 |
+
if not bookings:
|
| 231 |
+
raise HTTPException(status_code=404, detail="No bookings found for this phone number")
|
| 232 |
+
return {"bookings": bookings}
|
| 233 |
+
|
| 234 |
+
@app.get("/bookings/{booking_id}")
|
| 235 |
+
def booking_details(booking_id: str):
|
| 236 |
+
booking = get_booking_by_id(booking_id)
|
| 237 |
+
if not booking:
|
| 238 |
+
raise HTTPException(status_code=404, detail="Booking not found")
|
| 239 |
+
return booking
|
| 240 |
+
|
| 241 |
+
@app.delete("/bookings/{booking_id}")
|
| 242 |
+
def delete_booking_endpoint(booking_id: str, permanent: Optional[bool] = False):
|
| 243 |
+
if permanent:
|
| 244 |
+
success = delete_booking_permanently(booking_id)
|
| 245 |
+
if success:
|
| 246 |
+
return {"message": f"Booking {booking_id} deleted permanently."}
|
| 247 |
+
raise HTTPException(status_code=404, detail="Booking not found")
|
| 248 |
+
else:
|
| 249 |
+
success = cancel_booking(booking_id)
|
| 250 |
+
if success:
|
| 251 |
+
return {"message": f"Booking {booking_id} cancelled."}
|
| 252 |
+
raise HTTPException(status_code=404, detail="Booking not found")
|
| 253 |
+
|
| 254 |
+
@app.post("/query/smart")
|
| 255 |
+
def query_smart(request: QueryRequest):
|
| 256 |
+
session_id = request.session_id or str(uuid.uuid4())
|
| 257 |
+
if session_id not in sessions:
|
| 258 |
+
sessions[session_id] = {
|
| 259 |
+
"awaiting_booking_id": False,
|
| 260 |
+
"awaiting_phone_for_cancel": False,
|
| 261 |
+
"pending_bookings": [],
|
| 262 |
+
"phone": None
|
| 263 |
+
}
|
| 264 |
+
session = sessions[session_id]
|
| 265 |
+
query_text = request.query.strip()
|
| 266 |
+
query_lower = query_text.lower()
|
| 267 |
+
|
| 268 |
+
save_chat_message(session_id, "user", request.query, request.phone)
|
| 269 |
+
|
| 270 |
+
if session.get("awaiting_phone_for_cancel"):
|
| 271 |
+
phone = query_text
|
| 272 |
+
if phone.startswith("+88"):
|
| 273 |
+
phone = phone[3:]
|
| 274 |
+
session["phone"] = phone
|
| 275 |
+
session["awaiting_phone_for_cancel"] = False
|
| 276 |
+
bookings = get_bookings_by_phone(phone)
|
| 277 |
+
active_bookings = [b for b in bookings if b['status'] == 'active']
|
| 278 |
+
if not active_bookings:
|
| 279 |
+
message = f"No active bookings found for phone number {phone}"
|
| 280 |
+
save_chat_message(session_id, "assistant", message, phone)
|
| 281 |
+
return {"message": message, "session_id": session_id}
|
| 282 |
+
if len(active_bookings) == 1:
|
| 283 |
+
cancel_booking(active_bookings[0]['booking_id'])
|
| 284 |
+
message = f"Booking {active_bookings[0]['booking_id']} has been cancelled successfully."
|
| 285 |
+
save_chat_message(session_id, "assistant", message, phone)
|
| 286 |
+
return {"message": message, "session_id": session_id}
|
| 287 |
+
session["awaiting_booking_id"] = True
|
| 288 |
+
session["pending_bookings"] = active_bookings
|
| 289 |
+
message = "You have multiple active bookings:\n"
|
| 290 |
+
for b in active_bookings:
|
| 291 |
+
message += f"{b['from_district']} to {b['to_district']} on {b['travel_date']} (ID: {b['booking_id']})\n"
|
| 292 |
+
message += "\nPlease provide the Booking ID you want to cancel."
|
| 293 |
+
save_chat_message(session_id, "assistant", message, phone)
|
| 294 |
+
return {"message": message, "session_id": session_id}
|
| 295 |
+
|
| 296 |
+
if session["awaiting_booking_id"]:
|
| 297 |
+
booking_id = query_text
|
| 298 |
+
booking = next((b for b in session["pending_bookings"] if b["booking_id"] == booking_id), None)
|
| 299 |
+
if booking:
|
| 300 |
+
cancel_booking(booking_id)
|
| 301 |
+
session["awaiting_booking_id"] = False
|
| 302 |
+
session["pending_bookings"] = []
|
| 303 |
+
message = f"Booking {booking_id} has been cancelled successfully."
|
| 304 |
+
save_chat_message(session_id, "assistant", message, session.get("phone"))
|
| 305 |
+
return {"message": message, "session_id": session_id}
|
| 306 |
+
else:
|
| 307 |
+
message = f"Booking ID {booking_id} not found. Please check again."
|
| 308 |
+
save_chat_message(session_id, "assistant", message, session.get("phone"))
|
| 309 |
+
return {"message": message, "session_id": session_id}
|
| 310 |
+
|
| 311 |
+
if any(k in query_lower for k in ["cancel", "cancellation"]):
|
| 312 |
+
phone = (request.phone or session.get("phone") or "").strip()
|
| 313 |
+
if not phone:
|
| 314 |
+
session["awaiting_phone_for_cancel"] = True
|
| 315 |
+
message = "To cancel your booking, please provide your phone number."
|
| 316 |
+
save_chat_message(session_id, "assistant", message, None)
|
| 317 |
+
return {"message": message, "session_id": session_id}
|
| 318 |
+
if phone.startswith("+88"):
|
| 319 |
+
phone = phone[3:]
|
| 320 |
+
session["phone"] = phone
|
| 321 |
+
bookings = get_bookings_by_phone(phone)
|
| 322 |
+
active_bookings = [b for b in bookings if b['status'] == 'active']
|
| 323 |
+
if not active_bookings:
|
| 324 |
+
message = f"No active bookings found for phone number {phone}"
|
| 325 |
+
save_chat_message(session_id, "assistant", message, phone)
|
| 326 |
+
return {"message": message, "session_id": session_id}
|
| 327 |
+
if len(active_bookings) == 1:
|
| 328 |
+
cancel_booking(active_bookings[0]['booking_id'])
|
| 329 |
+
message = f"Booking {active_bookings[0]['booking_id']} has been cancelled successfully."
|
| 330 |
+
save_chat_message(session_id, "assistant", message, phone)
|
| 331 |
+
return {"message": message, "session_id": session_id}
|
| 332 |
+
session["awaiting_booking_id"] = True
|
| 333 |
+
session["pending_bookings"] = active_bookings
|
| 334 |
+
message = "You have multiple active bookings:\n"
|
| 335 |
+
for b in active_bookings:
|
| 336 |
+
message += f"{b['from_district']} to {b['to_district']} on {b['travel_date']} (ID: {b['booking_id']})\n"
|
| 337 |
+
message += "\nPlease provide the Booking ID you want to cancel."
|
| 338 |
+
save_chat_message(session_id, "assistant", message, phone)
|
| 339 |
+
return {"message": message, "session_id": session_id}
|
| 340 |
+
|
| 341 |
+
answer = get_answer(request.query)
|
| 342 |
+
save_chat_message(session_id, "assistant", answer, session.get("phone"))
|
| 343 |
+
return {"message": answer, "session_id": session_id}
|
| 344 |
+
|
| 345 |
+
@app.post("/query/detailed")
|
| 346 |
+
def query_rag_with_sources(request: QueryRequest):
|
| 347 |
+
return get_answer_with_sources(request.query)
|
| 348 |
+
|
| 349 |
+
@app.post("/chat/clear")
|
| 350 |
+
def clear_chat(session_id: str):
|
| 351 |
+
if session_id in sessions:
|
| 352 |
+
sessions[session_id] = {
|
| 353 |
+
"awaiting_booking_id": False,
|
| 354 |
+
"awaiting_phone_for_cancel": False,
|
| 355 |
+
"pending_bookings": [],
|
| 356 |
+
"phone": None
|
| 357 |
+
}
|
| 358 |
+
return {"message": "Chat cleared"}
|
| 359 |
+
|
| 360 |
+
@app.get("/stats")
|
| 361 |
+
def stats():
|
| 362 |
+
return get_booking_statistics()
|
| 363 |
+
|
| 364 |
+
if __name__ == "__main__":
|
| 365 |
+
import uvicorn
|
| 366 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
backend/models.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List
|
| 3 |
+
|
| 4 |
+
class Booking(BaseModel):
|
| 5 |
+
name: str
|
| 6 |
+
phone: str
|
| 7 |
+
source: str
|
| 8 |
+
destination: str
|
| 9 |
+
provider: str
|
| 10 |
+
date: str
|
| 11 |
+
|
| 12 |
+
class SearchQuery(BaseModel):
|
| 13 |
+
source: str
|
| 14 |
+
destination: str
|
| 15 |
+
max_price: int = None
|
backend/rag_pipeline.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_community.embeddings import HuggingFaceEmbeddings
|
| 2 |
+
from langchain_chroma import Chroma
|
| 3 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 4 |
+
from langchain.prompts import PromptTemplate
|
| 5 |
+
from langchain_core.output_parsers import StrOutputParser
|
| 6 |
+
from langchain_core.runnables import RunnablePassthrough
|
| 7 |
+
from .data_loader import all_chunks
|
| 8 |
+
import os
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
# --- API Key ---
|
| 14 |
+
os.environ["GOOGLE_API_KEY"] = os.environ.get("GOOGLE_API_KEY", "")
|
| 15 |
+
|
| 16 |
+
# --- Embeddings ---
|
| 17 |
+
embedding_model = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
|
| 18 |
+
|
| 19 |
+
# --- Vector DB ---
|
| 20 |
+
vectordb = Chroma(
|
| 21 |
+
collection_name="bus_data",
|
| 22 |
+
embedding_function=embedding_model,
|
| 23 |
+
persist_directory="vectorstore"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Convert list metadata β strings
|
| 27 |
+
def clean_metadata(metadata):
|
| 28 |
+
cleaned = {}
|
| 29 |
+
for key, value in metadata.items():
|
| 30 |
+
if isinstance(value, list):
|
| 31 |
+
cleaned[key] = ", ".join(str(v) for v in value)
|
| 32 |
+
else:
|
| 33 |
+
cleaned[key] = value
|
| 34 |
+
return cleaned
|
| 35 |
+
|
| 36 |
+
# Load chunks if empty
|
| 37 |
+
if len(vectordb.get()["ids"]) == 0:
|
| 38 |
+
print("Adding chunks to vector DB...")
|
| 39 |
+
for chunk in all_chunks:
|
| 40 |
+
metadata = chunk["metadata"].copy()
|
| 41 |
+
if "provider" in metadata and metadata["provider"]:
|
| 42 |
+
metadata["provider"] = metadata["provider"].strip().lower()
|
| 43 |
+
vectordb.add_texts([chunk["content"]], metadatas=[clean_metadata(metadata)])
|
| 44 |
+
print(f"β
Added {len(all_chunks)} chunks.")
|
| 45 |
+
else:
|
| 46 |
+
print(f"βΉοΈ Vector database already contains {len(vectordb.get()['ids'])} chunks.")
|
| 47 |
+
|
| 48 |
+
# --- Gemini LLM ---
|
| 49 |
+
gemini_llm = ChatGoogleGenerativeAI(
|
| 50 |
+
temperature=0.3,
|
| 51 |
+
model="gemini-2.5-flash",
|
| 52 |
+
google_api_key=os.environ["GOOGLE_API_KEY"]
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# --- Enhanced Prompt ---
|
| 56 |
+
prompt_template = """You are a friendly and helpful bus service assistant for Bangladesh bus services.
|
| 57 |
+
|
| 58 |
+
CRITICAL INSTRUCTIONS - READ CAREFULLY:
|
| 59 |
+
1. If the user asks about a SPECIFIC bus provider (like Hanif, Ena, Desh Travel, etc.), ONLY use information from that provider's context.
|
| 60 |
+
2. NEVER mix contact information, policies, or details between different providers.
|
| 61 |
+
3. When answering about contact information, address, or policy, make absolutely sure you're looking at the correct provider's data.
|
| 62 |
+
4. If you're not certain which provider the information belongs to, say you don't know.
|
| 63 |
+
|
| 64 |
+
GENERAL INSTRUCTIONS:
|
| 65 |
+
- Answer ONLY from the context provided below
|
| 66 |
+
- Be conversational, friendly, and concise
|
| 67 |
+
- Always mention prices in "Taka"
|
| 68 |
+
- Use bullet points for lists
|
| 69 |
+
- If information is missing, say: "I don't have that information. Please contact the bus service directly."
|
| 70 |
+
|
| 71 |
+
Context Information:
|
| 72 |
+
{context}
|
| 73 |
+
|
| 74 |
+
User Question: {question}
|
| 75 |
+
|
| 76 |
+
Helpful Answer:"""
|
| 77 |
+
|
| 78 |
+
PROMPT = PromptTemplate(
|
| 79 |
+
template=prompt_template,
|
| 80 |
+
input_variables=["context", "question"]
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# ======================================================
|
| 84 |
+
# Detect provider from query
|
| 85 |
+
# ======================================================
|
| 86 |
+
def detect_provider_from_query(query: str):
|
| 87 |
+
query_lower = query.lower()
|
| 88 |
+
providers = ["hanif", "ena", "desh travel", "green line", "soudia", "shyamoli"]
|
| 89 |
+
for provider in providers:
|
| 90 |
+
if provider in query_lower:
|
| 91 |
+
return provider
|
| 92 |
+
return None
|
| 93 |
+
|
| 94 |
+
# ======================================================
|
| 95 |
+
# Format retrieved docs into a string
|
| 96 |
+
# ======================================================
|
| 97 |
+
def format_docs(docs):
|
| 98 |
+
return "\n\n".join(doc.page_content for doc in docs)
|
| 99 |
+
|
| 100 |
+
# ======================================================
|
| 101 |
+
# Build RAG chain
|
| 102 |
+
# ======================================================
|
| 103 |
+
def get_rag_chain(provider: str = None):
|
| 104 |
+
if provider:
|
| 105 |
+
retriever = vectordb.as_retriever(
|
| 106 |
+
search_type="similarity",
|
| 107 |
+
search_kwargs={"k": 10, "filter": {"provider": provider.strip().lower()}}
|
| 108 |
+
)
|
| 109 |
+
else:
|
| 110 |
+
retriever = vectordb.as_retriever(
|
| 111 |
+
search_type="similarity",
|
| 112 |
+
search_kwargs={"k": 20}
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
chain = (
|
| 116 |
+
{
|
| 117 |
+
"context": retriever | format_docs,
|
| 118 |
+
"question": RunnablePassthrough()
|
| 119 |
+
}
|
| 120 |
+
| PROMPT
|
| 121 |
+
| gemini_llm
|
| 122 |
+
| StrOutputParser()
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
return chain, retriever
|
| 126 |
+
|
| 127 |
+
# ======================================================
|
| 128 |
+
# Get answer
|
| 129 |
+
# ======================================================
|
| 130 |
+
def get_answer(query: str, provider: str = None):
|
| 131 |
+
provider = provider or detect_provider_from_query(query)
|
| 132 |
+
chain, _ = get_rag_chain(provider)
|
| 133 |
+
return chain.invoke(query)
|
| 134 |
+
|
| 135 |
+
def get_answer_with_sources(query: str, provider: str = None):
|
| 136 |
+
provider = provider or detect_provider_from_query(query)
|
| 137 |
+
chain, retriever = get_rag_chain(provider)
|
| 138 |
+
|
| 139 |
+
docs = retriever.invoke(query)
|
| 140 |
+
answer = chain.invoke(query)
|
| 141 |
+
|
| 142 |
+
return {
|
| 143 |
+
"answer": answer,
|
| 144 |
+
"source_documents": docs
|
| 145 |
+
}
|
data.json
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"districts": [
|
| 3 |
+
{
|
| 4 |
+
"name": "Dhaka",
|
| 5 |
+
"dropping_points": [
|
| 6 |
+
{"name": "Gabtoli", "price": 500},
|
| 7 |
+
{"name": "Mohakhali", "price": 550},
|
| 8 |
+
{"name": "Sayedabad", "price": 520}
|
| 9 |
+
]
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"name": "Chattogram",
|
| 13 |
+
"dropping_points": [
|
| 14 |
+
{"name": "Muradpur", "price": 600},
|
| 15 |
+
{"name": "Agrabad", "price": 650},
|
| 16 |
+
{"name": "Kaptai", "price": 620}
|
| 17 |
+
]
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"name": "Khulna",
|
| 21 |
+
"dropping_points": [
|
| 22 |
+
{"name": "Daulatpur", "price": 400},
|
| 23 |
+
{"name": "Khalishpur", "price": 420}
|
| 24 |
+
]
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"name": "Rajshahi",
|
| 28 |
+
"dropping_points": [
|
| 29 |
+
{"name": "Shah Makhdum", "price": 480},
|
| 30 |
+
{"name": "Bagha", "price": 500}
|
| 31 |
+
]
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"name": "Sylhet",
|
| 35 |
+
"dropping_points": [
|
| 36 |
+
{"name": "Zindabazar", "price": 700},
|
| 37 |
+
{"name": "Bimanbandar", "price": 720}
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"name": "Barishal",
|
| 42 |
+
"dropping_points": [
|
| 43 |
+
{"name": "Khanjahan", "price": 450},
|
| 44 |
+
{"name": "Bakerganj", "price": 470}
|
| 45 |
+
]
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"name": "Rangpur",
|
| 49 |
+
"dropping_points": [
|
| 50 |
+
{"name": "Kachari", "price": 480},
|
| 51 |
+
{"name": "Cantonment", "price": 500}
|
| 52 |
+
]
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"name": "Mymensingh",
|
| 56 |
+
"dropping_points": [
|
| 57 |
+
{"name": "Shahbazpur", "price": 460},
|
| 58 |
+
{"name": "Kewatkhali", "price": 480}
|
| 59 |
+
]
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"name": "Comilla",
|
| 63 |
+
"dropping_points": [
|
| 64 |
+
{"name": "Kotbari", "price": 530},
|
| 65 |
+
{"name": "Nangalkot", "price": 550}
|
| 66 |
+
]
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"name": "Bogra",
|
| 70 |
+
"dropping_points": [
|
| 71 |
+
{"name": "Shahjahanpur", "price": 470},
|
| 72 |
+
{"name": "Sariakandi", "price": 490}
|
| 73 |
+
]
|
| 74 |
+
}
|
| 75 |
+
],
|
| 76 |
+
"bus_providers": [
|
| 77 |
+
{
|
| 78 |
+
"name": "Desh Travel",
|
| 79 |
+
"coverage_districts": ["Dhaka", "Chattogram", "Sylhet", "Rangpur"]
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"name": "Hanif",
|
| 83 |
+
"coverage_districts": ["Dhaka", "Khulna", "Mymensingh", "Comilla"]
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"name": "Ena",
|
| 87 |
+
"coverage_districts": ["Chattogram", "Sylhet", "Barishal", "Bogra"]
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"name": "Green Line",
|
| 91 |
+
"coverage_districts": ["Khulna", "Rajshahi", "Mymensingh", "Rangpur"]
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"name": "Soudia",
|
| 95 |
+
"coverage_districts": ["Dhaka", "Rajshahi", "Barishal", "Comilla"]
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"name": "Shyamoli",
|
| 99 |
+
"coverage_districts": ["Chattogram", "Khulna", "Sylhet", "Bogra"]
|
| 100 |
+
}
|
| 101 |
+
]
|
| 102 |
+
}
|
| 103 |
+
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
app:
|
| 5 |
+
build: .
|
| 6 |
+
container_name: busgo_app
|
| 7 |
+
ports:
|
| 8 |
+
- "8000:8000"
|
| 9 |
+
volumes:
|
| 10 |
+
# Persist the SQLite database
|
| 11 |
+
- ./bus_bookings.db:/app/bus_bookings.db
|
| 12 |
+
# Persist the vector store
|
| 13 |
+
- ./vectorstore:/app/vectorstore
|
| 14 |
+
env_file:
|
| 15 |
+
- .env
|
| 16 |
+
restart: unless-stopped
|
dokerignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.Python
|
| 7 |
+
*.egg-info/
|
| 8 |
+
.git/
|
| 9 |
+
.gitignore
|
| 10 |
+
.env
|
| 11 |
+
*.md
|
| 12 |
+
chroma_db/
|
| 13 |
+
.dockerignore
|
requirements.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
pydantic
|
| 4 |
+
requests
|
| 5 |
+
streamlit==1.40.0
|
| 6 |
+
sqlite-utils==3.35.1
|
| 7 |
+
python-dotenv
|
| 8 |
+
langchain==0.3.27
|
| 9 |
+
langchain-community==0.3.29
|
| 10 |
+
langchain-text-splitters==0.3.11
|
| 11 |
+
langchain-huggingface==0.3.1
|
| 12 |
+
huggingface-hub>=0.33.4
|
| 13 |
+
sentence-transformers==3.0.0
|
| 14 |
+
langchain-chroma==0.2.6
|
| 15 |
+
chromadb==1.5.1
|
| 16 |
+
langchain-google-genai==2.0.10
|
| 17 |
+
google-generativeai==0.8.3
|
| 18 |
+
jinja2
|
| 19 |
+
aiofiles
|
| 20 |
+
python-multipart
|
static/css/style.css
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ===== CSS Variables ===== */
|
| 2 |
+
:root {
|
| 3 |
+
--bg: #0d0f14;
|
| 4 |
+
--surface: #161a24;
|
| 5 |
+
--surface2: #1e2435;
|
| 6 |
+
--border: #2a3045;
|
| 7 |
+
--accent: #f5a623;
|
| 8 |
+
--accent2: #ff6b35;
|
| 9 |
+
--text: #e8eaf0;
|
| 10 |
+
--text-muted: #7a8299;
|
| 11 |
+
--success: #2ecc71;
|
| 12 |
+
--danger: #e74c3c;
|
| 13 |
+
--radius: 12px;
|
| 14 |
+
--radius-sm: 8px;
|
| 15 |
+
--shadow: 0 4px 24px rgba(0,0,0,0.4);
|
| 16 |
+
--font-head: 'Syne', sans-serif;
|
| 17 |
+
--font-body: 'DM Sans', sans-serif;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/* ===== Reset ===== */
|
| 21 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 22 |
+
|
| 23 |
+
body {
|
| 24 |
+
background: var(--bg);
|
| 25 |
+
color: var(--text);
|
| 26 |
+
font-family: var(--font-body);
|
| 27 |
+
font-size: 15px;
|
| 28 |
+
line-height: 1.6;
|
| 29 |
+
min-height: 100vh;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/* ===== Navbar ===== */
|
| 33 |
+
.navbar {
|
| 34 |
+
display: flex;
|
| 35 |
+
align-items: center;
|
| 36 |
+
gap: 1.5rem;
|
| 37 |
+
padding: 0 2rem;
|
| 38 |
+
height: 64px;
|
| 39 |
+
background: var(--surface);
|
| 40 |
+
border-bottom: 1px solid var(--border);
|
| 41 |
+
position: sticky;
|
| 42 |
+
top: 0;
|
| 43 |
+
z-index: 100;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.logo {
|
| 47 |
+
font-family: var(--font-head);
|
| 48 |
+
font-size: 1.3rem;
|
| 49 |
+
font-weight: 800;
|
| 50 |
+
color: var(--accent);
|
| 51 |
+
text-decoration: none;
|
| 52 |
+
letter-spacing: -0.5px;
|
| 53 |
+
margin-right: auto;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.nav-links { display: flex; gap: 0.25rem; }
|
| 57 |
+
|
| 58 |
+
.nav-link {
|
| 59 |
+
text-decoration: none;
|
| 60 |
+
color: var(--text-muted);
|
| 61 |
+
font-size: 0.875rem;
|
| 62 |
+
font-weight: 500;
|
| 63 |
+
padding: 0.45rem 0.9rem;
|
| 64 |
+
border-radius: var(--radius-sm);
|
| 65 |
+
transition: all 0.2s;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.nav-link:hover { color: var(--text); background: var(--surface2); }
|
| 69 |
+
.nav-link.active { color: var(--accent); background: rgba(245,166,35,0.1); }
|
| 70 |
+
|
| 71 |
+
.nav-status {
|
| 72 |
+
font-size: 1.2rem;
|
| 73 |
+
color: var(--text-muted);
|
| 74 |
+
transition: color 0.3s;
|
| 75 |
+
}
|
| 76 |
+
.nav-status.online { color: var(--success); }
|
| 77 |
+
.nav-status.offline { color: var(--danger); }
|
| 78 |
+
|
| 79 |
+
/* ===== Main ===== */
|
| 80 |
+
.main-content {
|
| 81 |
+
max-width: 1100px;
|
| 82 |
+
margin: 0 auto;
|
| 83 |
+
padding: 2.5rem 1.5rem 4rem;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/* ===== Page Header ===== */
|
| 87 |
+
.page-header { margin-bottom: 2.5rem; }
|
| 88 |
+
|
| 89 |
+
.page-title {
|
| 90 |
+
font-family: var(--font-head);
|
| 91 |
+
font-size: 2.4rem;
|
| 92 |
+
font-weight: 800;
|
| 93 |
+
letter-spacing: -1px;
|
| 94 |
+
line-height: 1.1;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.accent { color: var(--accent); }
|
| 98 |
+
|
| 99 |
+
.page-sub {
|
| 100 |
+
color: var(--text-muted);
|
| 101 |
+
margin-top: 0.5rem;
|
| 102 |
+
font-size: 1rem;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/* ===== Cards ===== */
|
| 106 |
+
.card {
|
| 107 |
+
background: var(--surface);
|
| 108 |
+
border: 1px solid var(--border);
|
| 109 |
+
border-radius: var(--radius);
|
| 110 |
+
padding: 1.75rem;
|
| 111 |
+
margin-bottom: 1.5rem;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.step-label {
|
| 115 |
+
font-family: var(--font-head);
|
| 116 |
+
font-size: 0.75rem;
|
| 117 |
+
font-weight: 700;
|
| 118 |
+
letter-spacing: 1.5px;
|
| 119 |
+
text-transform: uppercase;
|
| 120 |
+
color: var(--accent);
|
| 121 |
+
margin-bottom: 1.25rem;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/* ===== Form ===== */
|
| 125 |
+
.form-grid {
|
| 126 |
+
display: grid;
|
| 127 |
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 128 |
+
gap: 1.25rem;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.form-group { display: flex; flex-direction: column; gap: 0.4rem; }
|
| 132 |
+
|
| 133 |
+
.form-label {
|
| 134 |
+
font-size: 0.8rem;
|
| 135 |
+
font-weight: 500;
|
| 136 |
+
color: var(--text-muted);
|
| 137 |
+
letter-spacing: 0.3px;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.form-input, .form-select {
|
| 141 |
+
background: var(--surface2);
|
| 142 |
+
border: 1px solid var(--border);
|
| 143 |
+
border-radius: var(--radius-sm);
|
| 144 |
+
padding: 0.65rem 0.9rem;
|
| 145 |
+
color: var(--text);
|
| 146 |
+
font-family: var(--font-body);
|
| 147 |
+
font-size: 0.9rem;
|
| 148 |
+
transition: border-color 0.2s;
|
| 149 |
+
outline: none;
|
| 150 |
+
width: 100%;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.form-input:focus, .form-select:focus { border-color: var(--accent); }
|
| 154 |
+
.form-select:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 155 |
+
.form-select option { background: var(--surface2); }
|
| 156 |
+
|
| 157 |
+
/* ===== Route Summary ===== */
|
| 158 |
+
.route-summary {
|
| 159 |
+
margin-top: 1.25rem;
|
| 160 |
+
padding: 0.85rem 1.1rem;
|
| 161 |
+
background: rgba(245,166,35,0.08);
|
| 162 |
+
border: 1px solid rgba(245,166,35,0.25);
|
| 163 |
+
border-radius: var(--radius-sm);
|
| 164 |
+
color: var(--accent);
|
| 165 |
+
font-size: 0.9rem;
|
| 166 |
+
font-weight: 500;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.total-box {
|
| 170 |
+
margin-top: 1rem;
|
| 171 |
+
padding: 0.85rem 1.1rem;
|
| 172 |
+
background: rgba(46,204,113,0.08);
|
| 173 |
+
border: 1px solid rgba(46,204,113,0.25);
|
| 174 |
+
border-radius: var(--radius-sm);
|
| 175 |
+
color: var(--success);
|
| 176 |
+
font-size: 0.95rem;
|
| 177 |
+
margin-bottom: 1.25rem;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
/* ===== Buttons ===== */
|
| 181 |
+
.btn {
|
| 182 |
+
display: inline-flex;
|
| 183 |
+
align-items: center;
|
| 184 |
+
justify-content: center;
|
| 185 |
+
gap: 0.4rem;
|
| 186 |
+
padding: 0.65rem 1.4rem;
|
| 187 |
+
border-radius: var(--radius-sm);
|
| 188 |
+
font-family: var(--font-body);
|
| 189 |
+
font-size: 0.9rem;
|
| 190 |
+
font-weight: 500;
|
| 191 |
+
cursor: pointer;
|
| 192 |
+
border: none;
|
| 193 |
+
transition: all 0.2s;
|
| 194 |
+
text-decoration: none;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.btn-primary {
|
| 198 |
+
background: var(--accent);
|
| 199 |
+
color: #0d0f14;
|
| 200 |
+
font-weight: 700;
|
| 201 |
+
}
|
| 202 |
+
.btn-primary:hover { background: #ffb94a; transform: translateY(-1px); }
|
| 203 |
+
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
| 204 |
+
|
| 205 |
+
.btn-outline {
|
| 206 |
+
background: transparent;
|
| 207 |
+
color: var(--text-muted);
|
| 208 |
+
border: 1px solid var(--border);
|
| 209 |
+
}
|
| 210 |
+
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
| 211 |
+
|
| 212 |
+
.btn-danger {
|
| 213 |
+
background: rgba(231,76,60,0.15);
|
| 214 |
+
color: var(--danger);
|
| 215 |
+
border: 1px solid rgba(231,76,60,0.3);
|
| 216 |
+
}
|
| 217 |
+
.btn-danger:hover { background: rgba(231,76,60,0.25); }
|
| 218 |
+
|
| 219 |
+
.btn-full { width: 100%; }
|
| 220 |
+
|
| 221 |
+
/* ===== Search card ===== */
|
| 222 |
+
.search-card .search-row { display: flex; gap: 1rem; align-items: center; }
|
| 223 |
+
|
| 224 |
+
/* ===== Bookings ===== */
|
| 225 |
+
.result-count {
|
| 226 |
+
font-size: 0.85rem;
|
| 227 |
+
color: var(--text-muted);
|
| 228 |
+
margin-bottom: 1rem;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.booking-item {
|
| 232 |
+
background: var(--surface);
|
| 233 |
+
border: 1px solid var(--border);
|
| 234 |
+
border-radius: var(--radius);
|
| 235 |
+
padding: 1.5rem;
|
| 236 |
+
margin-bottom: 1rem;
|
| 237 |
+
transition: border-color 0.2s;
|
| 238 |
+
}
|
| 239 |
+
.booking-item.active { border-left: 3px solid var(--success); }
|
| 240 |
+
.booking-item.cancelled { border-left: 3px solid var(--danger); opacity: 0.7; }
|
| 241 |
+
|
| 242 |
+
.booking-header {
|
| 243 |
+
display: flex;
|
| 244 |
+
justify-content: space-between;
|
| 245 |
+
align-items: center;
|
| 246 |
+
margin-bottom: 1rem;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.booking-id {
|
| 250 |
+
font-family: var(--font-head);
|
| 251 |
+
font-weight: 700;
|
| 252 |
+
font-size: 1rem;
|
| 253 |
+
color: var(--accent);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.booking-status {
|
| 257 |
+
font-size: 0.7rem;
|
| 258 |
+
font-weight: 700;
|
| 259 |
+
letter-spacing: 1px;
|
| 260 |
+
padding: 0.25rem 0.6rem;
|
| 261 |
+
border-radius: 4px;
|
| 262 |
+
}
|
| 263 |
+
.status-active { background: rgba(46,204,113,0.15); color: var(--success); }
|
| 264 |
+
.status-cancelled { background: rgba(231,76,60,0.15); color: var(--danger); }
|
| 265 |
+
|
| 266 |
+
.booking-body {
|
| 267 |
+
display: grid;
|
| 268 |
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 269 |
+
gap: 1rem;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.booking-field { margin-bottom: 0.5rem; }
|
| 273 |
+
.booking-field span { display: block; font-size: 0.75rem; color: var(--text-muted); }
|
| 274 |
+
.booking-field strong { font-size: 0.9rem; }
|
| 275 |
+
.total-field strong { color: var(--accent); font-size: 1rem; }
|
| 276 |
+
|
| 277 |
+
.booking-footer { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
| 278 |
+
|
| 279 |
+
/* ===== Providers ===== */
|
| 280 |
+
.providers-grid {
|
| 281 |
+
display: grid;
|
| 282 |
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
| 283 |
+
gap: 1.25rem;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.provider-card {
|
| 287 |
+
background: var(--surface);
|
| 288 |
+
border: 1px solid var(--border);
|
| 289 |
+
border-radius: var(--radius);
|
| 290 |
+
padding: 1.5rem;
|
| 291 |
+
display: flex;
|
| 292 |
+
flex-direction: column;
|
| 293 |
+
gap: 1rem;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.provider-header { display: flex; align-items: center; gap: 1rem; }
|
| 297 |
+
.provider-icon { font-size: 2rem; }
|
| 298 |
+
.provider-name { font-family: var(--font-head); font-weight: 700; font-size: 1.1rem; }
|
| 299 |
+
.provider-meta { font-size: 0.8rem; color: var(--text-muted); }
|
| 300 |
+
|
| 301 |
+
.coverage-details summary {
|
| 302 |
+
font-size: 0.85rem;
|
| 303 |
+
color: var(--text-muted);
|
| 304 |
+
cursor: pointer;
|
| 305 |
+
padding: 0.4rem 0;
|
| 306 |
+
}
|
| 307 |
+
.coverage-details summary:hover { color: var(--text); }
|
| 308 |
+
|
| 309 |
+
.district-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem; }
|
| 310 |
+
.district-tag {
|
| 311 |
+
background: var(--surface2);
|
| 312 |
+
border: 1px solid var(--border);
|
| 313 |
+
border-radius: 4px;
|
| 314 |
+
padding: 0.2rem 0.5rem;
|
| 315 |
+
font-size: 0.75rem;
|
| 316 |
+
color: var(--text-muted);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.policy-box {
|
| 320 |
+
background: var(--surface2);
|
| 321 |
+
border: 1px solid var(--border);
|
| 322 |
+
border-radius: var(--radius-sm);
|
| 323 |
+
padding: 1rem;
|
| 324 |
+
font-size: 0.82rem;
|
| 325 |
+
color: var(--text-muted);
|
| 326 |
+
white-space: pre-wrap;
|
| 327 |
+
max-height: 280px;
|
| 328 |
+
overflow-y: auto;
|
| 329 |
+
line-height: 1.7;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/* ===== Districts / Routes ===== */
|
| 333 |
+
.districts-grid {
|
| 334 |
+
display: grid;
|
| 335 |
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 336 |
+
gap: 1.25rem;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.district-card {
|
| 340 |
+
background: var(--surface);
|
| 341 |
+
border: 1px solid var(--border);
|
| 342 |
+
border-radius: var(--radius);
|
| 343 |
+
padding: 1.25rem;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.district-header {
|
| 347 |
+
display: flex;
|
| 348 |
+
justify-content: space-between;
|
| 349 |
+
align-items: center;
|
| 350 |
+
margin-bottom: 0.75rem;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.district-name { font-family: var(--font-head); font-weight: 700; font-size: 1rem; }
|
| 354 |
+
.district-count { font-size: 0.75rem; color: var(--text-muted); background: var(--surface2); padding: 0.2rem 0.5rem; border-radius: 4px; }
|
| 355 |
+
|
| 356 |
+
.price-range {
|
| 357 |
+
display: flex;
|
| 358 |
+
align-items: center;
|
| 359 |
+
gap: 0.4rem;
|
| 360 |
+
margin-bottom: 0.75rem;
|
| 361 |
+
font-size: 0.85rem;
|
| 362 |
+
}
|
| 363 |
+
.price-label { color: var(--text-muted); }
|
| 364 |
+
.price-val { color: var(--accent); font-weight: 600; }
|
| 365 |
+
|
| 366 |
+
.stops-details summary {
|
| 367 |
+
font-size: 0.82rem;
|
| 368 |
+
color: var(--text-muted);
|
| 369 |
+
cursor: pointer;
|
| 370 |
+
padding: 0.3rem 0;
|
| 371 |
+
}
|
| 372 |
+
.stops-list { margin-top: 0.5rem; }
|
| 373 |
+
.stop-row {
|
| 374 |
+
display: flex;
|
| 375 |
+
justify-content: space-between;
|
| 376 |
+
align-items: center;
|
| 377 |
+
padding: 0.35rem 0;
|
| 378 |
+
border-bottom: 1px solid var(--border);
|
| 379 |
+
font-size: 0.82rem;
|
| 380 |
+
}
|
| 381 |
+
.stop-row:last-child { border-bottom: none; }
|
| 382 |
+
.stop-price { color: var(--accent); font-weight: 600; }
|
| 383 |
+
|
| 384 |
+
/* ===== AI Chat ===== */
|
| 385 |
+
.chat-layout {
|
| 386 |
+
display: grid;
|
| 387 |
+
grid-template-columns: 260px 1fr;
|
| 388 |
+
gap: 1.5rem;
|
| 389 |
+
height: calc(100vh - 280px);
|
| 390 |
+
min-height: 500px;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
.chat-sidebar {
|
| 394 |
+
background: var(--surface);
|
| 395 |
+
border: 1px solid var(--border);
|
| 396 |
+
border-radius: var(--radius);
|
| 397 |
+
padding: 1.25rem;
|
| 398 |
+
display: flex;
|
| 399 |
+
flex-direction: column;
|
| 400 |
+
gap: 1.25rem;
|
| 401 |
+
overflow-y: auto;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.sidebar-title {
|
| 405 |
+
font-size: 0.75rem;
|
| 406 |
+
font-weight: 700;
|
| 407 |
+
letter-spacing: 0.5px;
|
| 408 |
+
text-transform: uppercase;
|
| 409 |
+
color: var(--text-muted);
|
| 410 |
+
margin-bottom: 0.6rem;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.sidebar-hint { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.4rem; }
|
| 414 |
+
|
| 415 |
+
.suggestions { display: flex; flex-direction: column; gap: 0.4rem; }
|
| 416 |
+
.suggestion-btn {
|
| 417 |
+
background: var(--surface2);
|
| 418 |
+
border: 1px solid var(--border);
|
| 419 |
+
border-radius: var(--radius-sm);
|
| 420 |
+
padding: 0.5rem 0.75rem;
|
| 421 |
+
color: var(--text-muted);
|
| 422 |
+
font-size: 0.8rem;
|
| 423 |
+
cursor: pointer;
|
| 424 |
+
text-align: left;
|
| 425 |
+
transition: all 0.2s;
|
| 426 |
+
font-family: var(--font-body);
|
| 427 |
+
}
|
| 428 |
+
.suggestion-btn:hover { border-color: var(--accent); color: var(--text); }
|
| 429 |
+
|
| 430 |
+
.chat-main {
|
| 431 |
+
background: var(--surface);
|
| 432 |
+
border: 1px solid var(--border);
|
| 433 |
+
border-radius: var(--radius);
|
| 434 |
+
display: flex;
|
| 435 |
+
flex-direction: column;
|
| 436 |
+
overflow: hidden;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.chat-messages {
|
| 440 |
+
flex: 1;
|
| 441 |
+
overflow-y: auto;
|
| 442 |
+
padding: 1.5rem;
|
| 443 |
+
display: flex;
|
| 444 |
+
flex-direction: column;
|
| 445 |
+
gap: 1rem;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
.chat-message { display: flex; }
|
| 449 |
+
.chat-message.user { justify-content: flex-end; }
|
| 450 |
+
|
| 451 |
+
.msg-bubble {
|
| 452 |
+
max-width: 70%;
|
| 453 |
+
padding: 0.75rem 1rem;
|
| 454 |
+
border-radius: var(--radius);
|
| 455 |
+
font-size: 0.9rem;
|
| 456 |
+
line-height: 1.6;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
.chat-message.user .msg-bubble {
|
| 460 |
+
background: var(--accent);
|
| 461 |
+
color: #0d0f14;
|
| 462 |
+
font-weight: 500;
|
| 463 |
+
border-bottom-right-radius: 4px;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.chat-message.assistant .msg-bubble {
|
| 467 |
+
background: var(--surface2);
|
| 468 |
+
border: 1px solid var(--border);
|
| 469 |
+
color: var(--text);
|
| 470 |
+
border-bottom-left-radius: 4px;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
/* Typing animation */
|
| 474 |
+
.typing { display: flex; align-items: center; gap: 5px; padding: 1rem; }
|
| 475 |
+
.typing span {
|
| 476 |
+
width: 7px; height: 7px;
|
| 477 |
+
background: var(--text-muted);
|
| 478 |
+
border-radius: 50%;
|
| 479 |
+
animation: bounce 1.2s infinite;
|
| 480 |
+
}
|
| 481 |
+
.typing span:nth-child(2) { animation-delay: 0.2s; }
|
| 482 |
+
.typing span:nth-child(3) { animation-delay: 0.4s; }
|
| 483 |
+
@keyframes bounce { 0%,60%,100%{transform:translateY(0)} 30%{transform:translateY(-8px)} }
|
| 484 |
+
|
| 485 |
+
.chat-input-area {
|
| 486 |
+
display: flex;
|
| 487 |
+
gap: 0.75rem;
|
| 488 |
+
padding: 1rem 1.25rem;
|
| 489 |
+
border-top: 1px solid var(--border);
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
.chat-input {
|
| 493 |
+
flex: 1;
|
| 494 |
+
background: var(--surface2);
|
| 495 |
+
border: 1px solid var(--border);
|
| 496 |
+
border-radius: var(--radius-sm);
|
| 497 |
+
padding: 0.65rem 1rem;
|
| 498 |
+
color: var(--text);
|
| 499 |
+
font-family: var(--font-body);
|
| 500 |
+
font-size: 0.9rem;
|
| 501 |
+
outline: none;
|
| 502 |
+
}
|
| 503 |
+
.chat-input:focus { border-color: var(--accent); }
|
| 504 |
+
|
| 505 |
+
/* ===== Modal ===== */
|
| 506 |
+
.modal {
|
| 507 |
+
position: fixed;
|
| 508 |
+
inset: 0;
|
| 509 |
+
background: rgba(0,0,0,0.7);
|
| 510 |
+
display: flex;
|
| 511 |
+
align-items: center;
|
| 512 |
+
justify-content: center;
|
| 513 |
+
z-index: 200;
|
| 514 |
+
backdrop-filter: blur(4px);
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.modal-box {
|
| 518 |
+
background: var(--surface);
|
| 519 |
+
border: 1px solid var(--border);
|
| 520 |
+
border-radius: var(--radius);
|
| 521 |
+
padding: 2rem;
|
| 522 |
+
max-width: 480px;
|
| 523 |
+
width: 90%;
|
| 524 |
+
text-align: center;
|
| 525 |
+
box-shadow: var(--shadow);
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
.modal-icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
|
| 529 |
+
.modal-box h2 { font-family: var(--font-head); font-size: 1.5rem; margin-bottom: 1.25rem; }
|
| 530 |
+
|
| 531 |
+
.booking-result { text-align: left; margin-bottom: 1.5rem; }
|
| 532 |
+
.result-row {
|
| 533 |
+
display: flex;
|
| 534 |
+
justify-content: space-between;
|
| 535 |
+
padding: 0.5rem 0;
|
| 536 |
+
border-bottom: 1px solid var(--border);
|
| 537 |
+
font-size: 0.875rem;
|
| 538 |
+
}
|
| 539 |
+
.result-row span { color: var(--text-muted); }
|
| 540 |
+
.result-row:last-child { border-bottom: none; }
|
| 541 |
+
.total-row strong { color: var(--accent); font-size: 1rem; }
|
| 542 |
+
|
| 543 |
+
/* ===== Toast ===== */
|
| 544 |
+
.toast {
|
| 545 |
+
position: fixed;
|
| 546 |
+
bottom: 2rem;
|
| 547 |
+
right: 2rem;
|
| 548 |
+
background: var(--surface2);
|
| 549 |
+
border: 1px solid var(--border);
|
| 550 |
+
border-radius: var(--radius-sm);
|
| 551 |
+
padding: 0.75rem 1.25rem;
|
| 552 |
+
font-size: 0.875rem;
|
| 553 |
+
z-index: 300;
|
| 554 |
+
animation: slideIn 0.3s ease;
|
| 555 |
+
box-shadow: var(--shadow);
|
| 556 |
+
}
|
| 557 |
+
.toast.success { border-left: 3px solid var(--success); }
|
| 558 |
+
.toast.error { border-left: 3px solid var(--danger); }
|
| 559 |
+
@keyframes slideIn { from { transform: translateX(100px); opacity: 0; } to { transform: none; opacity: 1; } }
|
| 560 |
+
|
| 561 |
+
/* ===== Utilities ===== */
|
| 562 |
+
.hidden { display: none !important; }
|
| 563 |
+
.loading { padding: 2rem; text-align: center; color: var(--text-muted); }
|
| 564 |
+
.empty-state {
|
| 565 |
+
text-align: center;
|
| 566 |
+
padding: 3rem;
|
| 567 |
+
color: var(--text-muted);
|
| 568 |
+
background: var(--surface);
|
| 569 |
+
border: 1px solid var(--border);
|
| 570 |
+
border-radius: var(--radius);
|
| 571 |
+
font-size: 1rem;
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
/* ===== Scrollbar ===== */
|
| 575 |
+
::-webkit-scrollbar { width: 6px; }
|
| 576 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 577 |
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
| 578 |
+
|
| 579 |
+
/* ===== Responsive ===== */
|
| 580 |
+
@media (max-width: 768px) {
|
| 581 |
+
.navbar { padding: 0 1rem; }
|
| 582 |
+
.nav-links { display: none; }
|
| 583 |
+
.main-content { padding: 1.5rem 1rem; }
|
| 584 |
+
.page-title { font-size: 1.8rem; }
|
| 585 |
+
.form-grid { grid-template-columns: 1fr; }
|
| 586 |
+
.chat-layout { grid-template-columns: 1fr; height: auto; }
|
| 587 |
+
.chat-sidebar { display: none; }
|
| 588 |
+
.booking-body { grid-template-columns: 1fr; }
|
| 589 |
+
.search-card .search-row { flex-direction: column; }
|
| 590 |
+
}
|
static/js/main.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ===== Backend Status Check =====
|
| 2 |
+
async function checkStatus() {
|
| 3 |
+
const dot = document.getElementById('statusDot');
|
| 4 |
+
if (!dot) return;
|
| 5 |
+
try {
|
| 6 |
+
const res = await fetch('/stats', { signal: AbortSignal.timeout(2000) });
|
| 7 |
+
if (res.ok) {
|
| 8 |
+
dot.classList.add('online');
|
| 9 |
+
dot.classList.remove('offline');
|
| 10 |
+
dot.title = 'Backend connected';
|
| 11 |
+
} else {
|
| 12 |
+
throw new Error();
|
| 13 |
+
}
|
| 14 |
+
} catch {
|
| 15 |
+
dot.classList.add('offline');
|
| 16 |
+
dot.classList.remove('online');
|
| 17 |
+
dot.title = 'Backend offline';
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
checkStatus();
|
| 21 |
+
|
| 22 |
+
// ===== Toast Notifications =====
|
| 23 |
+
function showToast(message, type = 'success') {
|
| 24 |
+
const existing = document.querySelector('.toast');
|
| 25 |
+
if (existing) existing.remove();
|
| 26 |
+
|
| 27 |
+
const toast = document.createElement('div');
|
| 28 |
+
toast.className = `toast ${type}`;
|
| 29 |
+
toast.textContent = message;
|
| 30 |
+
document.body.appendChild(toast);
|
| 31 |
+
|
| 32 |
+
setTimeout(() => {
|
| 33 |
+
toast.style.transition = 'opacity 0.3s';
|
| 34 |
+
toast.style.opacity = '0';
|
| 35 |
+
setTimeout(() => toast.remove(), 300);
|
| 36 |
+
}, 3000);
|
| 37 |
+
}
|
templates/assistant.html
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}AI Assistant β BusGo{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
|
| 5 |
+
<div class="page-header">
|
| 6 |
+
<h1 class="page-title">AI <span class="accent">Assistant</span></h1>
|
| 7 |
+
<p class="page-sub">Ask anything about routes, fares, providers, or your bookings</p>
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<div class="chat-layout">
|
| 11 |
+
<div class="chat-sidebar">
|
| 12 |
+
<div class="sidebar-section">
|
| 13 |
+
<div class="sidebar-title">π± Your Phone (optional)</div>
|
| 14 |
+
<input type="text" id="userPhone" class="form-input" placeholder="01XXXXXXXXX">
|
| 15 |
+
<p class="sidebar-hint">Required only for booking-related queries like cancellations.</p>
|
| 16 |
+
</div>
|
| 17 |
+
<div class="sidebar-section">
|
| 18 |
+
<div class="sidebar-title">π‘ Try asking:</div>
|
| 19 |
+
<div class="suggestions">
|
| 20 |
+
<button class="suggestion-btn" onclick="fillSuggestion(this)">Buses from Dhaka to Rajshahi under 500 taka?</button>
|
| 21 |
+
<button class="suggestion-btn" onclick="fillSuggestion(this)">What are contact details of Hanif Bus?</button>
|
| 22 |
+
<button class="suggestion-btn" onclick="fillSuggestion(this)">Which bus is cheapest to Cox's Bazar?</button>
|
| 23 |
+
<button class="suggestion-btn" onclick="fillSuggestion(this)">Cancel my booking</button>
|
| 24 |
+
<button class="suggestion-btn" onclick="fillSuggestion(this)">What is Ena Paribahan's policy?</button>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
<button class="btn btn-outline btn-full" onclick="clearChat()" style="margin-top:auto">ποΈ Clear Chat</button>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<div class="chat-main">
|
| 31 |
+
<div class="chat-messages" id="chatMessages">
|
| 32 |
+
<div class="chat-message assistant">
|
| 33 |
+
<div class="msg-bubble">
|
| 34 |
+
π Hello! I'm your BusGo AI assistant. Ask me about bus routes, fares, provider policies, or your bookings. If you need help with a booking cancellation, enter your phone number on the left first.
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<div class="chat-input-area">
|
| 40 |
+
<input type="text" id="chatInput" class="chat-input" placeholder="Type your question..." onkeydown="if(event.key==='Enter') sendMessage()">
|
| 41 |
+
<button class="btn btn-primary chat-send" onclick="sendMessage()">Send β€</button>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
{% endblock %}
|
| 47 |
+
|
| 48 |
+
{% block scripts %}
|
| 49 |
+
<script>
|
| 50 |
+
const sessionId = 'session-' + Math.random().toString(36).slice(2);
|
| 51 |
+
|
| 52 |
+
function fillSuggestion(btn) {
|
| 53 |
+
document.getElementById('chatInput').value = btn.textContent;
|
| 54 |
+
document.getElementById('chatInput').focus();
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
async function sendMessage() {
|
| 58 |
+
const input = document.getElementById('chatInput');
|
| 59 |
+
const query = input.value.trim();
|
| 60 |
+
if (!query) return;
|
| 61 |
+
|
| 62 |
+
appendMessage('user', query);
|
| 63 |
+
input.value = '';
|
| 64 |
+
|
| 65 |
+
let phone = document.getElementById('userPhone').value.trim();
|
| 66 |
+
if (phone.startsWith('+88')) phone = phone.slice(3);
|
| 67 |
+
|
| 68 |
+
const typingId = appendTyping();
|
| 69 |
+
|
| 70 |
+
try {
|
| 71 |
+
const payload = { query, session_id: sessionId };
|
| 72 |
+
if (phone) payload.phone = phone;
|
| 73 |
+
|
| 74 |
+
const res = await fetch('/query/smart', {
|
| 75 |
+
method: 'POST',
|
| 76 |
+
headers: { 'Content-Type': 'application/json' },
|
| 77 |
+
body: JSON.stringify(payload)
|
| 78 |
+
});
|
| 79 |
+
const data = await res.json();
|
| 80 |
+
removeTyping(typingId);
|
| 81 |
+
appendMessage('assistant', data.message || 'Sorry, I could not process that.');
|
| 82 |
+
} catch(e) {
|
| 83 |
+
removeTyping(typingId);
|
| 84 |
+
appendMessage('assistant', 'β οΈ Connection error. Please check if the backend is running.');
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function appendMessage(role, text) {
|
| 89 |
+
const container = document.getElementById('chatMessages');
|
| 90 |
+
const div = document.createElement('div');
|
| 91 |
+
div.className = `chat-message ${role}`;
|
| 92 |
+
div.innerHTML = `<div class="msg-bubble">${text.replace(/\n/g, '<br>')}</div>`;
|
| 93 |
+
container.appendChild(div);
|
| 94 |
+
container.scrollTop = container.scrollHeight;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function appendTyping() {
|
| 98 |
+
const container = document.getElementById('chatMessages');
|
| 99 |
+
const id = 'typing-' + Date.now();
|
| 100 |
+
const div = document.createElement('div');
|
| 101 |
+
div.className = 'chat-message assistant';
|
| 102 |
+
div.id = id;
|
| 103 |
+
div.innerHTML = `<div class="msg-bubble typing"><span></span><span></span><span></span></div>`;
|
| 104 |
+
container.appendChild(div);
|
| 105 |
+
container.scrollTop = container.scrollHeight;
|
| 106 |
+
return id;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function removeTyping(id) {
|
| 110 |
+
const el = document.getElementById(id);
|
| 111 |
+
if (el) el.remove();
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
async function clearChat() {
|
| 115 |
+
document.getElementById('chatMessages').innerHTML = `
|
| 116 |
+
<div class="chat-message assistant">
|
| 117 |
+
<div class="msg-bubble">Chat cleared! How can I help you?</div>
|
| 118 |
+
</div>`;
|
| 119 |
+
try {
|
| 120 |
+
await fetch(`/chat/clear?session_id=${sessionId}`, { method: 'POST' });
|
| 121 |
+
} catch(e) {}
|
| 122 |
+
}
|
| 123 |
+
</script>
|
| 124 |
+
{% endblock %}
|
templates/base.html
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>{% block title %}BusGo β Ticket Booking{% endblock %}</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
| 9 |
+
<link rel="stylesheet" href="/static/css/style.css">
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
|
| 13 |
+
<nav class="navbar">
|
| 14 |
+
<a href="/" class="logo">π BusGo</a>
|
| 15 |
+
<div class="nav-links">
|
| 16 |
+
<a href="/" class="nav-link {% if active == 'book' %}active{% endif %}">Book Ticket</a>
|
| 17 |
+
<a href="/bookings-page" class="nav-link {% if active == 'bookings' %}active{% endif %}">My Bookings</a>
|
| 18 |
+
<a href="/providers-page" class="nav-link {% if active == 'providers' %}active{% endif %}">Providers</a>
|
| 19 |
+
<a href="/routes-page" class="nav-link {% if active == 'routes' %}active{% endif %}">Routes & Fares</a>
|
| 20 |
+
<a href="/assistant-page" class="nav-link {% if active == 'assistant' %}active{% endif %}">AI Assistant</a>
|
| 21 |
+
</div>
|
| 22 |
+
<div class="nav-status" id="statusDot" title="Checking backend...">β</div>
|
| 23 |
+
</nav>
|
| 24 |
+
|
| 25 |
+
<main class="main-content">
|
| 26 |
+
{% block content %}{% endblock %}
|
| 27 |
+
</main>
|
| 28 |
+
|
| 29 |
+
<script src="/static/js/main.js"></script>
|
| 30 |
+
{% block scripts %}{% endblock %}
|
| 31 |
+
</body>
|
| 32 |
+
</html>
|
templates/bookings.html
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}My Bookings β BusGo{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
|
| 5 |
+
<div class="page-header">
|
| 6 |
+
<h1 class="page-title">My <span class="accent">Bookings</span></h1>
|
| 7 |
+
<p class="page-sub">View and manage your ticket reservations</p>
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<div class="card search-card">
|
| 11 |
+
<div class="search-row">
|
| 12 |
+
<input type="text" id="phoneInput" class="form-input" placeholder="Enter phone number β 01XXXXXXXXX" style="flex:1">
|
| 13 |
+
<button class="btn btn-primary" onclick="searchBookings()">π Search</button>
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<div id="bookingsResult"></div>
|
| 18 |
+
|
| 19 |
+
{% endblock %}
|
| 20 |
+
|
| 21 |
+
{% block scripts %}
|
| 22 |
+
<script>
|
| 23 |
+
async function searchBookings() {
|
| 24 |
+
let phone = document.getElementById('phoneInput').value.trim();
|
| 25 |
+
if (phone.startsWith('+88')) phone = phone.slice(3);
|
| 26 |
+
if (!phone) return showToast('Please enter your phone number.', 'error');
|
| 27 |
+
|
| 28 |
+
const result = document.getElementById('bookingsResult');
|
| 29 |
+
result.innerHTML = '<div class="loading">Searching...</div>';
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
const res = await fetch(`/bookings/phone/${encodeURIComponent(phone)}`);
|
| 33 |
+
const data = await res.json();
|
| 34 |
+
|
| 35 |
+
if (!res.ok) {
|
| 36 |
+
result.innerHTML = `<div class="empty-state">π No bookings found for this number.</div>`;
|
| 37 |
+
return;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const bookings = data.bookings || [];
|
| 41 |
+
if (!bookings.length) {
|
| 42 |
+
result.innerHTML = `<div class="empty-state">π No bookings found.</div>`;
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
result.innerHTML = `<div class="result-count">Found ${bookings.length} booking(s)</div>` +
|
| 47 |
+
bookings.map(b => `
|
| 48 |
+
<div class="booking-item ${b.status}" id="booking-${b.booking_id}">
|
| 49 |
+
<div class="booking-header">
|
| 50 |
+
<div class="booking-id">${b.booking_id}</div>
|
| 51 |
+
<div class="booking-status status-${b.status}">${b.status.toUpperCase()}</div>
|
| 52 |
+
</div>
|
| 53 |
+
<div class="booking-body">
|
| 54 |
+
<div class="booking-col">
|
| 55 |
+
<div class="booking-field"><span>Passenger</span><strong>${b.name}</strong></div>
|
| 56 |
+
<div class="booking-field"><span>Phone</span><strong>${b.phone}</strong></div>
|
| 57 |
+
<div class="booking-field"><span>Passengers</span><strong>${b.num_passengers}</strong></div>
|
| 58 |
+
</div>
|
| 59 |
+
<div class="booking-col">
|
| 60 |
+
<div class="booking-field"><span>Provider</span><strong>${b.bus_provider}</strong></div>
|
| 61 |
+
<div class="booking-field"><span>Route</span><strong>${b.from_district} β ${b.to_district}</strong></div>
|
| 62 |
+
<div class="booking-field"><span>Dropping Point</span><strong>${b.dropping_point}</strong></div>
|
| 63 |
+
<div class="booking-field"><span>Travel Date</span><strong>${b.travel_date}</strong></div>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="booking-col">
|
| 66 |
+
<div class="booking-field"><span>Fare/person</span><strong>${b.fare} Taka</strong></div>
|
| 67 |
+
<div class="booking-field total-field"><span>Total</span><strong>${b.total_amount} Taka</strong></div>
|
| 68 |
+
<div class="booking-field"><span>Booked On</span><strong>${b.booking_date.slice(0,10)}</strong></div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
${b.status === 'active' ? `
|
| 72 |
+
<div class="booking-footer">
|
| 73 |
+
<button class="btn btn-danger" onclick="cancelBooking('${b.booking_id}')">β Cancel Booking</button>
|
| 74 |
+
</div>` : ''}
|
| 75 |
+
</div>`).join('');
|
| 76 |
+
} catch(e) {
|
| 77 |
+
result.innerHTML = `<div class="empty-state">β οΈ Connection error. Is the backend running?</div>`;
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
async function cancelBooking(bookingId) {
|
| 82 |
+
if (!confirm('Are you sure you want to cancel this booking?')) return;
|
| 83 |
+
try {
|
| 84 |
+
const res = await fetch(`/bookings/${bookingId}?permanent=false`, { method: 'DELETE' });
|
| 85 |
+
if (res.ok) {
|
| 86 |
+
const item = document.getElementById(`booking-${bookingId}`);
|
| 87 |
+
item.querySelector('.booking-status').textContent = 'CANCELLED';
|
| 88 |
+
item.querySelector('.booking-status').className = 'booking-status status-cancelled';
|
| 89 |
+
item.querySelector('.booking-footer').remove();
|
| 90 |
+
item.classList.remove('active');
|
| 91 |
+
item.classList.add('cancelled');
|
| 92 |
+
showToast('Booking cancelled successfully.', 'success');
|
| 93 |
+
} else {
|
| 94 |
+
const d = await res.json();
|
| 95 |
+
showToast(d.detail || 'Failed to cancel.', 'error');
|
| 96 |
+
}
|
| 97 |
+
} catch(e) { showToast('Connection error.', 'error'); }
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// Allow Enter key to search
|
| 101 |
+
document.getElementById('phoneInput').addEventListener('keydown', e => {
|
| 102 |
+
if (e.key === 'Enter') searchBookings();
|
| 103 |
+
});
|
| 104 |
+
</script>
|
| 105 |
+
{% endblock %}
|
templates/index.html
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Book Ticket β BusGo{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
|
| 5 |
+
<div class="page-header">
|
| 6 |
+
<h1 class="page-title">Book Your <span class="accent">Ticket</span></h1>
|
| 7 |
+
<p class="page-sub">Fast, simple bus booking across Bangladesh</p>
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<div class="card booking-card">
|
| 11 |
+
<div class="step-label">Step 1 β Route Details</div>
|
| 12 |
+
|
| 13 |
+
<div class="form-grid">
|
| 14 |
+
<div class="form-group">
|
| 15 |
+
<label class="form-label">Bus Provider</label>
|
| 16 |
+
<select id="providerSelect" class="form-select" onchange="onProviderChange()">
|
| 17 |
+
<option value="">Select provider...</option>
|
| 18 |
+
{% for p in providers %}
|
| 19 |
+
<option value="{{ p.name }}" data-districts="{{ p.coverage_districts | join(',') }}">{{ p.name }}</option>
|
| 20 |
+
{% endfor %}
|
| 21 |
+
</select>
|
| 22 |
+
</div>
|
| 23 |
+
|
| 24 |
+
<div class="form-group">
|
| 25 |
+
<label class="form-label">From District</label>
|
| 26 |
+
<select id="fromDistrict" class="form-select" onchange="onFromChange()" disabled>
|
| 27 |
+
<option value="">Select provider first...</option>
|
| 28 |
+
</select>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<div class="form-group">
|
| 32 |
+
<label class="form-label">To District</label>
|
| 33 |
+
<select id="toDistrict" class="form-select" onchange="onToChange()" disabled>
|
| 34 |
+
<option value="">Select from district...</option>
|
| 35 |
+
</select>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div class="form-group">
|
| 39 |
+
<label class="form-label">Dropping Point</label>
|
| 40 |
+
<select id="droppingPoint" class="form-select" onchange="onDropChange()" disabled>
|
| 41 |
+
<option value="">Select destination...</option>
|
| 42 |
+
</select>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div id="routeSummary" class="route-summary hidden">
|
| 47 |
+
<span id="summaryText"></span>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div class="card booking-card">
|
| 52 |
+
<div class="step-label">Step 2 β Passenger Details</div>
|
| 53 |
+
|
| 54 |
+
<div class="form-grid">
|
| 55 |
+
<div class="form-group">
|
| 56 |
+
<label class="form-label">Full Name</label>
|
| 57 |
+
<input type="text" id="passengerName" class="form-input" placeholder="Enter your full name">
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<div class="form-group">
|
| 61 |
+
<label class="form-label">Phone Number</label>
|
| 62 |
+
<input type="text" id="passengerPhone" class="form-input" placeholder="01XXXXXXXXX">
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div class="form-group">
|
| 66 |
+
<label class="form-label">Number of Passengers</label>
|
| 67 |
+
<input type="number" id="numPassengers" class="form-input" value="1" min="1" max="10" onchange="updateTotal()">
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div class="form-group">
|
| 71 |
+
<label class="form-label">Travel Date</label>
|
| 72 |
+
<input type="date" id="travelDate" class="form-input">
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<div id="totalBox" class="total-box hidden">
|
| 77 |
+
π° Total: <strong id="totalAmount">0</strong> Taka
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<button class="btn btn-primary btn-full" onclick="submitBooking()">
|
| 81 |
+
π« Confirm Booking
|
| 82 |
+
</button>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<!-- Success Modal -->
|
| 86 |
+
<div id="successModal" class="modal hidden">
|
| 87 |
+
<div class="modal-box">
|
| 88 |
+
<div class="modal-icon">β
</div>
|
| 89 |
+
<h2>Booking Confirmed!</h2>
|
| 90 |
+
<div id="modalContent" class="modal-content"></div>
|
| 91 |
+
<button class="btn btn-primary" onclick="closeModal()">Done</button>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{% endblock %}
|
| 96 |
+
|
| 97 |
+
{% block scripts %}
|
| 98 |
+
<script>
|
| 99 |
+
// Set min date to tomorrow
|
| 100 |
+
const tomorrow = new Date();
|
| 101 |
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
| 102 |
+
document.getElementById('travelDate').min = tomorrow.toISOString().split('T')[0];
|
| 103 |
+
document.getElementById('travelDate').value = tomorrow.toISOString().split('T')[0];
|
| 104 |
+
|
| 105 |
+
function onProviderChange() {
|
| 106 |
+
const sel = document.getElementById('providerSelect');
|
| 107 |
+
const opt = sel.options[sel.selectedIndex];
|
| 108 |
+
const from = document.getElementById('fromDistrict');
|
| 109 |
+
const to = document.getElementById('toDistrict');
|
| 110 |
+
const dp = document.getElementById('droppingPoint');
|
| 111 |
+
|
| 112 |
+
from.innerHTML = '<option value="">Select from district...</option>';
|
| 113 |
+
to.innerHTML = '<option value="">Select to district...</option>';
|
| 114 |
+
dp.innerHTML = '<option value="">Select destination first...</option>';
|
| 115 |
+
to.disabled = true;
|
| 116 |
+
dp.disabled = true;
|
| 117 |
+
hideRouteSummary();
|
| 118 |
+
|
| 119 |
+
if (!opt.value) { from.disabled = true; return; }
|
| 120 |
+
|
| 121 |
+
const districts = opt.dataset.districts.split(',');
|
| 122 |
+
districts.forEach(d => {
|
| 123 |
+
const o = document.createElement('option');
|
| 124 |
+
o.value = d.trim(); o.textContent = d.trim();
|
| 125 |
+
from.appendChild(o);
|
| 126 |
+
});
|
| 127 |
+
from.disabled = false;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function onFromChange() {
|
| 131 |
+
const from = document.getElementById('fromDistrict').value;
|
| 132 |
+
const provSel = document.getElementById('providerSelect');
|
| 133 |
+
const opt = provSel.options[provSel.selectedIndex];
|
| 134 |
+
const to = document.getElementById('toDistrict');
|
| 135 |
+
const dp = document.getElementById('droppingPoint');
|
| 136 |
+
|
| 137 |
+
to.innerHTML = '<option value="">Select to district...</option>';
|
| 138 |
+
dp.innerHTML = '<option value="">Select destination first...</option>';
|
| 139 |
+
dp.disabled = true;
|
| 140 |
+
hideRouteSummary();
|
| 141 |
+
|
| 142 |
+
if (!from) { to.disabled = true; return; }
|
| 143 |
+
|
| 144 |
+
const districts = opt.dataset.districts.split(',').map(d => d.trim()).filter(d => d !== from);
|
| 145 |
+
districts.forEach(d => {
|
| 146 |
+
const o = document.createElement('option');
|
| 147 |
+
o.value = d; o.textContent = d;
|
| 148 |
+
to.appendChild(o);
|
| 149 |
+
});
|
| 150 |
+
to.disabled = false;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
async function onToChange() {
|
| 154 |
+
const to = document.getElementById('toDistrict').value;
|
| 155 |
+
const dp = document.getElementById('droppingPoint');
|
| 156 |
+
dp.innerHTML = '<option value="">Loading...</option>';
|
| 157 |
+
dp.disabled = true;
|
| 158 |
+
hideRouteSummary();
|
| 159 |
+
|
| 160 |
+
if (!to) return;
|
| 161 |
+
|
| 162 |
+
const res = await fetch(`/dropping-points/${encodeURIComponent(to)}`);
|
| 163 |
+
const data = await res.json();
|
| 164 |
+
dp.innerHTML = '<option value="">Select dropping point...</option>';
|
| 165 |
+
(data.dropping_points || []).forEach(pt => {
|
| 166 |
+
const o = document.createElement('option');
|
| 167 |
+
o.value = pt.name;
|
| 168 |
+
o.textContent = `${pt.name} β ${pt.price} Taka`;
|
| 169 |
+
o.dataset.price = pt.price;
|
| 170 |
+
dp.appendChild(o);
|
| 171 |
+
});
|
| 172 |
+
dp.disabled = false;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
function onDropChange() {
|
| 176 |
+
updateTotal();
|
| 177 |
+
const from = document.getElementById('fromDistrict').value;
|
| 178 |
+
const to = document.getElementById('toDistrict').value;
|
| 179 |
+
const dp = document.getElementById('droppingPoint');
|
| 180 |
+
const opt = dp.options[dp.selectedIndex];
|
| 181 |
+
if (opt.value) {
|
| 182 |
+
document.getElementById('summaryText').textContent =
|
| 183 |
+
`β
${from} β ${to} | ${opt.value} | ${opt.dataset.price} Taka/person`;
|
| 184 |
+
document.getElementById('routeSummary').classList.remove('hidden');
|
| 185 |
+
} else {
|
| 186 |
+
hideRouteSummary();
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
function updateTotal() {
|
| 191 |
+
const dp = document.getElementById('droppingPoint');
|
| 192 |
+
const opt = dp.options[dp.selectedIndex];
|
| 193 |
+
const n = parseInt(document.getElementById('numPassengers').value) || 1;
|
| 194 |
+
if (opt && opt.dataset.price) {
|
| 195 |
+
const total = parseInt(opt.dataset.price) * n;
|
| 196 |
+
document.getElementById('totalAmount').textContent = total;
|
| 197 |
+
document.getElementById('totalBox').classList.remove('hidden');
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function hideRouteSummary() {
|
| 202 |
+
document.getElementById('routeSummary').classList.add('hidden');
|
| 203 |
+
document.getElementById('totalBox').classList.add('hidden');
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
async function submitBooking() {
|
| 207 |
+
const provider = document.getElementById('providerSelect').value;
|
| 208 |
+
const from = document.getElementById('fromDistrict').value;
|
| 209 |
+
const to = document.getElementById('toDistrict').value;
|
| 210 |
+
const dp = document.getElementById('droppingPoint').value;
|
| 211 |
+
const name = document.getElementById('passengerName').value.trim();
|
| 212 |
+
let phone = document.getElementById('passengerPhone').value.trim();
|
| 213 |
+
const n = parseInt(document.getElementById('numPassengers').value);
|
| 214 |
+
const date = document.getElementById('travelDate').value;
|
| 215 |
+
|
| 216 |
+
if (!provider || !from || !to || !dp) return showToast('Please complete route selection.', 'error');
|
| 217 |
+
if (!name) return showToast('Please enter your name.', 'error');
|
| 218 |
+
if (phone.startsWith('+88')) phone = phone.slice(3);
|
| 219 |
+
if (phone.length < 11 || !/^\d+$/.test(phone)) return showToast('Enter a valid 11-digit phone number.', 'error');
|
| 220 |
+
|
| 221 |
+
const btn = document.querySelector('.btn-primary');
|
| 222 |
+
btn.disabled = true; btn.textContent = 'Booking...';
|
| 223 |
+
|
| 224 |
+
try {
|
| 225 |
+
const res = await fetch('/bookings', {
|
| 226 |
+
method: 'POST',
|
| 227 |
+
headers: {'Content-Type': 'application/json'},
|
| 228 |
+
body: JSON.stringify({
|
| 229 |
+
name, phone, bus_provider: provider,
|
| 230 |
+
from_district: from, to_district: to,
|
| 231 |
+
dropping_point: dp, travel_date: date, num_passengers: n
|
| 232 |
+
})
|
| 233 |
+
});
|
| 234 |
+
const data = await res.json();
|
| 235 |
+
if (res.ok) {
|
| 236 |
+
document.getElementById('modalContent').innerHTML = `
|
| 237 |
+
<div class="booking-result">
|
| 238 |
+
<div class="result-row"><span>Booking ID</span><strong>${data.booking_id}</strong></div>
|
| 239 |
+
<div class="result-row"><span>Name</span><strong>${data.name}</strong></div>
|
| 240 |
+
<div class="result-row"><span>Route</span><strong>${data.from_district} β ${data.to_district}</strong></div>
|
| 241 |
+
<div class="result-row"><span>Dropping Point</span><strong>${data.dropping_point}</strong></div>
|
| 242 |
+
<div class="result-row"><span>Provider</span><strong>${data.bus_provider}</strong></div>
|
| 243 |
+
<div class="result-row"><span>Date</span><strong>${data.travel_date}</strong></div>
|
| 244 |
+
<div class="result-row"><span>Passengers</span><strong>${data.num_passengers}</strong></div>
|
| 245 |
+
<div class="result-row total-row"><span>Total</span><strong>${data.total_amount} Taka</strong></div>
|
| 246 |
+
</div>`;
|
| 247 |
+
document.getElementById('successModal').classList.remove('hidden');
|
| 248 |
+
} else {
|
| 249 |
+
showToast(data.detail || 'Booking failed.', 'error');
|
| 250 |
+
}
|
| 251 |
+
} catch(e) { showToast('Connection error.', 'error'); }
|
| 252 |
+
btn.disabled = false; btn.textContent = 'π« Confirm Booking';
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
function closeModal() {
|
| 256 |
+
document.getElementById('successModal').classList.add('hidden');
|
| 257 |
+
location.reload();
|
| 258 |
+
}
|
| 259 |
+
</script>
|
| 260 |
+
{% endblock %}
|
templates/providers.html
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Providers β BusGo{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
|
| 5 |
+
<div class="page-header">
|
| 6 |
+
<h1 class="page-title">Bus <span class="accent">Providers</span></h1>
|
| 7 |
+
<p class="page-sub">Explore all available bus services</p>
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<div class="providers-grid">
|
| 11 |
+
{% for provider in providers %}
|
| 12 |
+
<div class="provider-card">
|
| 13 |
+
<div class="provider-header">
|
| 14 |
+
<div class="provider-icon">π</div>
|
| 15 |
+
<div>
|
| 16 |
+
<h3 class="provider-name">{{ provider.name }}</h3>
|
| 17 |
+
<div class="provider-meta">{{ provider.coverage_districts | length }} districts covered</div>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<details class="coverage-details">
|
| 22 |
+
<summary>View Coverage Districts</summary>
|
| 23 |
+
<div class="district-tags">
|
| 24 |
+
{% for d in provider.coverage_districts %}
|
| 25 |
+
<span class="district-tag">{{ d }}</span>
|
| 26 |
+
{% endfor %}
|
| 27 |
+
</div>
|
| 28 |
+
</details>
|
| 29 |
+
|
| 30 |
+
<button class="btn btn-outline btn-full" onclick="loadPolicy('{{ provider.name }}', this)">
|
| 31 |
+
π View Policy
|
| 32 |
+
</button>
|
| 33 |
+
|
| 34 |
+
<div id="policy-{{ loop.index }}" class="policy-box hidden" data-index="{{ loop.index }}"></div>
|
| 35 |
+
</div>
|
| 36 |
+
{% endfor %}
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
{% endblock %}
|
| 40 |
+
|
| 41 |
+
{% block scripts %}
|
| 42 |
+
<script>
|
| 43 |
+
async function loadPolicy(providerName, btn) {
|
| 44 |
+
const idx = btn.closest('.provider-card').querySelector('.policy-box').dataset.index;
|
| 45 |
+
const box = document.getElementById(`policy-${idx}`);
|
| 46 |
+
|
| 47 |
+
if (!box.classList.contains('hidden')) {
|
| 48 |
+
box.classList.add('hidden');
|
| 49 |
+
btn.textContent = 'π View Policy';
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
btn.textContent = 'Loading...';
|
| 54 |
+
btn.disabled = true;
|
| 55 |
+
|
| 56 |
+
try {
|
| 57 |
+
const res = await fetch(`/providers/${encodeURIComponent(providerName)}/policy`);
|
| 58 |
+
const data = await res.json();
|
| 59 |
+
if (res.ok) {
|
| 60 |
+
box.textContent = data.policy;
|
| 61 |
+
box.classList.remove('hidden');
|
| 62 |
+
btn.textContent = 'π Hide Policy';
|
| 63 |
+
} else {
|
| 64 |
+
showToast('Policy not available for this provider.', 'error');
|
| 65 |
+
btn.textContent = 'π View Policy';
|
| 66 |
+
}
|
| 67 |
+
} catch(e) {
|
| 68 |
+
showToast('Connection error.', 'error');
|
| 69 |
+
btn.textContent = 'π View Policy';
|
| 70 |
+
}
|
| 71 |
+
btn.disabled = false;
|
| 72 |
+
}
|
| 73 |
+
</script>
|
| 74 |
+
{% endblock %}
|
templates/routes.html
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Routes & Fares β BusGo{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
|
| 5 |
+
<div class="page-header">
|
| 6 |
+
<h1 class="page-title">Routes & <span class="accent">Fares</span></h1>
|
| 7 |
+
<p class="page-sub">Browse all destinations and ticket prices</p>
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<div class="card" style="margin-bottom: 2rem;">
|
| 11 |
+
<input type="text" id="searchInput" class="form-input" placeholder="π Search district or dropping point..." oninput="filterDistricts()">
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
<div class="districts-grid" id="districtsGrid">
|
| 15 |
+
{% for district in districts %}
|
| 16 |
+
<div class="district-card" data-name="{{ district.name | lower }}" data-points="{{ district.dropping_points | map(attribute='name') | join(',') | lower }}">
|
| 17 |
+
<div class="district-header">
|
| 18 |
+
<h3 class="district-name">π {{ district.name }}</h3>
|
| 19 |
+
<div class="district-count">{{ district.dropping_points | length }} stops</div>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
{% if district.dropping_points %}
|
| 23 |
+
<div class="price-range">
|
| 24 |
+
<span class="price-label">From</span>
|
| 25 |
+
<span class="price-val">{{ district.dropping_points | map(attribute='price') | min }} Taka</span>
|
| 26 |
+
<span class="price-label">to</span>
|
| 27 |
+
<span class="price-val">{{ district.dropping_points | map(attribute='price') | max }} Taka</span>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<details class="stops-details">
|
| 31 |
+
<summary>View Dropping Points</summary>
|
| 32 |
+
<div class="stops-list">
|
| 33 |
+
{% for dp in district.dropping_points %}
|
| 34 |
+
<div class="stop-row">
|
| 35 |
+
<span class="stop-name">π {{ dp.name }}</span>
|
| 36 |
+
<span class="stop-price">{{ dp.price }} Taka</span>
|
| 37 |
+
</div>
|
| 38 |
+
{% endfor %}
|
| 39 |
+
</div>
|
| 40 |
+
</details>
|
| 41 |
+
{% endif %}
|
| 42 |
+
</div>
|
| 43 |
+
{% endfor %}
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
{% endblock %}
|
| 47 |
+
|
| 48 |
+
{% block scripts %}
|
| 49 |
+
<script>
|
| 50 |
+
function filterDistricts() {
|
| 51 |
+
const q = document.getElementById('searchInput').value.toLowerCase();
|
| 52 |
+
document.querySelectorAll('.district-card').forEach(card => {
|
| 53 |
+
const name = card.dataset.name;
|
| 54 |
+
const points = card.dataset.points;
|
| 55 |
+
card.style.display = (name.includes(q) || points.includes(q)) ? '' : 'none';
|
| 56 |
+
});
|
| 57 |
+
}
|
| 58 |
+
</script>
|
| 59 |
+
{% endblock %}
|