main create

This commit is contained in:
2026-06-13 16:06:24 +07:00
parent 3bb7649533
commit 568cb0e0bc
69 changed files with 9551 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.env
node_modules
+120
View File
@@ -0,0 +1,120 @@
# Kiến Trúc Hệ Thống: Tính Năng Lập Kế Hoạch Du Lịch (Travel Planning Feature)
Tài liệu này mô tả kiến trúc tổng thể, mô hình dữ liệu và các giải pháp kỹ thuật cho tính năng lập kế hoạch du lịch đa người dùng thuộc nền tảng **Travel**.
---
## 1. Kiến Trúc Tổng Quan (System Overview)
Hệ thống được thiết kế theo mô hình **Client-Server** kết hợp với kiến trúc **Modular Monolith** hoặc **Microservices** (tùy quy mô triển khai), chia làm 3 tầng chính:
* **Frontend (Presentation Layer):** Đảm nhiệm việc tương tác trực tiếp với người dùng, hiển thị bản đồ trực quan, quản lý luồng tạo tour/chặng và xử lý tải ảnh/hiển thị album.
* **Backend (Business Logic Layer):** Xử lý xác thực bảo mật, tính toán chi phí (quy tắc phân chia tiền), định tuyến đường đi ngắn nhất, lập lịch kiểm tra task (Cron job/Event-driven) và kiểm tra quyền truy cập (RBAC).
* **Database & Storage (Data Layer):** Lưu trữ dữ liệu quan hệ (PostgreSQL với extension PostGIS để xử lý tọa độ địa lý) và lưu trữ tệp tin hình ảnh riêng biệt (Object Storage như AWS S3 hoặc MinIO).
---
## 2. Công Nghệ Đề Xuất (Tech Stack)
| Thành phần | Công nghệ đề xuất | Lý do lựa chọn |
| :--- | :--- | :--- |
| **Frontend** | React / Next.js + TailwindCSS | Tối ưu cho ứng dụng Single Page (SPA), quản lý trạng thái tốt, hỗ trợ SSR cho SEO nếu cần. |
| **State Management** | Zustand / Redux Toolkit | Quản lý state phức tạp của Tour, Chặng (Leg) và Chi phí. |
| **Map Engine** | Mapbox GL JS / Google Maps API / Leaflet | Hỗ trợ hiển thị bản đồ trực quan, marker kéo thả và tính toán định tuyến (Routing). |
| **Backend** | Node.js (NestJS hoặc Express) | Xử lý bất đồng bộ tốt, phù hợp hệ thống nhiều I/O và tích hợp mDNS/IoT nếu mở rộng sau này. |
| **Database** | PostgreSQL + **PostGIS** | Hệ quản trị cơ sở dữ liệu mạnh mẽ, PostGIS giúp tính toán khoảng cách và tọa độ địa lý chính xác. |
| **File Storage** | AWS S3 / MinIO | Lưu trữ ảnh tách biệt hoàn toàn khỏi Database để tối ưu hiệu năng. |
---
## 3. Mô Hình Dữ Liệu (Database Schema)
Dưới đây là các thực thể cốt lõi phục vụ tính năng:
### 3.1. Users & Authentication
* `users`: Lưu thông tin định danh (`id`, `email`, `password_hash`, `name`, `avatar`).
### 3.2. Tour & Members (Quản lý đa người dùng & Phân quyền)
* `tours`: Thông tin tổng quan về chuyến đi.
* `id` (PK), `title`, `start_date`, `end_date`, `creator_id` (FK), `created_at`.
* `tour_members`: Bảng trung gian quản lý thành viên và quyền hạn (Bảo mật thông tin).
* `tour_id` (FK), `user_id` (FK).
* `role`: Định nghĩa các quyền cụ thể:
* `OWNER`: Toàn quyền (Người tạo).
* `EDITOR`: Sửa đổi kế hoạch, chi phí, xem/thêm ảnh.
* `MEMBER_PLAN_ONLY`: Chỉ xem/sửa kế hoạch, không xem được chi phí.
* `MEMBER_PHOTO_ONLY`: Chỉ được xem/đăng ảnh trong album, không thấy kế hoạch và chi phí.
* `VIEWER_EXTERNAL`: Người ngoài được share link, chỉ xem được ảnh công khai (tùy thuộc vào cài đặt privacy).
### 3.3. Itinerary & Map (Chặng & Địa điểm)
* `legs` (Chặng): Một tour có nhiều chặng.
* `id` (PK), `tour_id` (FK), `sequence_number` (Thứ tự chặng: 1, 2, 3...), `notes`.
* `places` (Địa điểm trong chặng):
* `id` (PK), `leg_id` (FK), `name`, `address`, `latitude`, `longitude` (Dữ liệu PostGIS), `sequence_in_leg`.
* `arrival_time` (Dự kiến), `departure_time` (Dự kiến).
### 3.4. Expenses (Chi phí)
* `expenses`: Lưu vết chi phí cho từng địa điểm/chặng.
* `id` (PK), `leg_id` (FK), `place_id` (FK, nullable), `category` (`LODGING`, `DINING`, `TRANSPORT`, `OTHER`), `amount` (Số tiền), `currency`, `description`.
* `tour_cost_summaries`: Cấu hình phân chia tiền ở cuối tour/chặng.
* `tour_id` (FK), `total_cost`, `adult_count`, `child_count`, `child_discount_percent` (Ví dụ: trẻ em giảm 30%).
### 3.5. Tasks & Timeline Tracking (Theo dõi tiến độ)
* `tasks`: Các hoạt động cần tích chọn hoàn thành.
* `id` (PK), `tour_id` (FK), `leg_id` (FK), `title`, `planned_timestamp`.
* `is_completed` (Boolean).
* `completed_at` (Timestamp - Ghi lại lúc hệ thống tự động tích hoặc user chủ động tích).
* `trigger_type` (`AUTO_BY_TIME` hoặc `MANUAL_BY_USER`).
### 3.6. Album Ảnh (Tách biệt logic kế hoạch)
* `photos`: Lưu trữ hình ảnh của tour gắn với địa điểm.
* `id` (PK), `tour_id` (FK), `place_id` (FK, nullable), `uploader_id` (FK), `image_url` (Đường dẫn S3), `captured_at` (Metadata từ ảnh hoặc thời gian tạo), `privacy_level` (`PUBLIC_IN_TOUR`, `PRIVATE_OWNER`).
---
## 4. Giải Pháp Kiến Trúc Cho Các Tính Năng Core
### 4.1. Thuật Toán Tìm Đường Ngắn Nhất (Shortest Path Routing)
* **Cơ chế:** Khi người dùng thêm các địa điểm vào một chặng, Frontend sẽ gửi danh sách tọa độ hình học (Lat, Lng) về Backend.
* **Xử lý:** Backend tích hợp với **Mapbox Direction API** hoặc chạy một instance **OSRM (Open Source Routing Machine)** cục bộ để giải bài toán Người đi giao hàng (TSP - Travelling Salesman Problem). Hệ thống sẽ sắp xếp lại thứ tự `sequence_in_leg` của các `places` để tối ưu tổng quãng đường hoặc thời gian di chuyển, sau đó trả về mảng tọa độ đường đi (Polyline) để Frontend vẽ lên bản đồ.
### 4.2. Engine Tính Toán Chi Phí & Chia Tiền (Expense Splitting Engine)
* **Công thức tự động:** * Tổng chi phí Tour ($T$) = $\sum \text{Expenses}$ của tất cả các chặng.
* Hệ thống cho phép cấu hình số lượng Người lớn ($N_{adult}$) và Trẻ em ($N_{child}$), kèm phần trăm giảm giá của trẻ em ($P_{child\_discount}$).
* Gọi $X$ là chi phí của 1 người lớn:
$$T = N_{adult} \times X + N_{child} \times \left( X \times (1 - P_{child\_discount}) \right)$$
* Từ đó Backend tự động giải phương trình để tìm ra chi phí trung bình của người lớn ($X$) và trẻ em, hiển thị báo cáo chi tiết ở cuối mỗi chặng và kết thúc tour.
### 4.3. Theo Dõi Tiến Độ & Tính Toán Độ Lệch Thời Gian (Time Variance Tracking)
* **Tự động (Auto-trigger):** Một Worker/Cron-job chạy ngầm mỗi phút quét các `tasks` có cài đặt `AUTO_BY_TIME`. Nếu `current_time >= planned_timestamp` và chưa hoàn thành, hệ thống tự động chuyển `is_completed = true` và ghi nhận `completed_at = planned_timestamp`.
* **Thủ công (Manual-trigger):** Khi user bấm tích chọn hoàn thành, hệ thống ghi nhận ngay `completed_at = current_time`.
* **Báo cáo thời gian (Time Summary):** Backend tính toán hiệu số:
$$\Delta t = \text{completed\_at} - \text{planned\_timestamp}$$
Đồng thời group dữ liệu theo loại hoạt động để xuất ra tổng thời gian: *Di chuyển (dựa trên khoảng cách bản đồ), Tham quan (thời gian ở các địa điểm giải trí), Nghỉ ngơi (thời gian ở chỗ nghỉ).*
### 4.4. Bảo Mật & Tách Biệt Album Ảnh (Photo Album Isolation)
* **Tách biệt logic:** Cơ sở dữ liệu ảnh liên kết qua mã `tour_id``place_id` nhưng API endpoint kiểm tra quyền (`/api/v1/tours/:id/photos`) hoạt động độc lập với endpoint kế hoạch (`/api/v1/tours/:id/plans`).
* **Bảo mật phân quyền (Privacy Middleware):** Trước khi trả về dữ liệu hình ảnh hoặc kế hoạch, hệ thống sẽ chạy qua một tầng Middleware để check quyền trong bảng `tour_members`:
```javascript
// Giả lập Logic kiểm tra quyền truy cập thông tin tài chính/kế hoạch
if (member.role === 'MEMBER_PHOTO_ONLY' && request.path.includes('/plans')) {
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
}
```
---
## 5. Thiết Kế Luồng Đi (Workflow Diagrams)
### Luồng tạo chặng và tính toán đường đi (Frontend <=> Backend)
1. **User** tìm kiếm/chọn địa điểm trên bản đồ -> **Frontend** cập nhật state cục bộ.
2. **User** nhấn "Tối ưu đường đi" -> **Frontend** gửi danh sách `coords[]` lên `/api/v1/routing/optimize`.
3. **Backend** gọi Mapbox/OSRM API -> Sắp xếp lại thứ tự tối ưu -> Lưu vào DB -> Trả về JSON chứa `optimized_places` và `polyline_geometry`.
4. **Frontend** render lại danh sách địa điểm theo thứ tự mới và vẽ nét đứt/đường đi trên Map.
### Luồng chia sẻ hình ảnh và kiểm tra bảo mật
1. **User** tải ảnh lên tại một địa điểm trong Tour.
2. **Frontend** gửi file đến dịch vụ lưu trữ (S3) nhận về `image_url`, sau đó lưu thông tin vào DB kèm metadata (ngày giờ, địa điểm).
3. Khi một **User khác** truy cập link Tour:
* Hệ thống check `tour_members`.
* Nếu thuộc diện *External Viewer* hoặc *Photo Only*: Ẩn hoàn toàn tab "Kế hoạch chặng hành trình" và "Tổng số tiền", chỉ hiển thị giao diện Grid hình ảnh (`album`).
+38
View File
@@ -0,0 +1,38 @@
import React, { useState } from 'react';
import { LandingPage } from './LandingPage';
import { TourDetailPage } from './TourDetailPage';
import { ExploreMap } from './ExploreMap';
import { SignupPage } from './SignupPage';
const App = () => {
type View = 'landing' | 'explore' | 'detail' | 'signup';
const [view, setView] = useState<View>('landing');
return (
<div className="app-container">
{view === 'landing' && (
<LandingPage
onContinue={() => setView('explore')}
onGoToSignup={() => setView('signup')}
/>
)}
{view === 'signup' && (
<SignupPage
onBack={() => setView('landing')}
onSuccess={() => setView('landing')}
/>
)}
{view === 'explore' && (
<ExploreMap onBack={() => setView('landing')} />
)}
{view === 'detail' && (
<TourDetailPage />
)}
</div>
);
};
export default App;
+77
View File
@@ -0,0 +1,77 @@
import React, { useState, useMemo } from 'react';
import { Wallet, Users, Info } from 'lucide-react';
import { useTourStore } from './useTourStore';
export const ExpenseManager = () => {
const { legs } = useTourStore();
const [adults, setAdults] = useState(2);
const [children, setChildren] = useState(1);
const [discount, setDiscount] = useState(30);
const totals = useMemo(() => {
const totalAmount = legs.reduce((acc, leg) =>
acc + leg.expenses.reduce((lAcc: number, exp: any) => lAcc + Number(exp.amount), 0), 0
);
const childRateFactor = 1 - (discount / 100);
const weightedCount = adults + (children * childRateFactor);
const adultPrice = totalAmount / weightedCount;
const childPrice = adultPrice * childRateFactor;
return {
total: totalAmount,
adultPrice: Math.round(adultPrice),
childPrice: Math.round(childPrice)
};
}, [legs, adults, children, discount]);
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
<div className="flex items-center gap-2 mb-6 text-blue-600">
<Users className="w-5 h-5" />
<h3 className="font-bold">Cấu hình thành viên</h3>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Người lớn</label>
<input type="number" value={adults} onChange={e => setAdults(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Trẻ em</label>
<input type="number" value={children} onChange={e => setChildren(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Giảm trẻ em (%)</label>
<input type="number" value={discount} onChange={e => setDiscount(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
<div className="bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200">
<div className="flex justify-between items-start mb-8">
<div>
<p className="text-blue-100 text-sm">Tổng chi phí chuyến đi</p>
<h2 className="text-3xl font-bold mt-1">{totals.total.toLocaleString()} VND</h2>
</div>
<Wallet className="w-8 h-8 opacity-20" />
</div>
<div className="grid grid-cols-2 gap-4 border-t border-blue-500 pt-6">
<div>
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi người lớn</p>
<p className="text-xl font-bold">{totals.adultPrice.toLocaleString()}đ</p>
</div>
<div>
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi trẻ em (-{discount}%)</p>
<p className="text-xl font-bold">{totals.childPrice.toLocaleString()}đ</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 text-gray-400 text-xs px-2">
<Info className="w-4 h-4" />
<p>Chi phí đưc tự đng tính toán dựa trên hóa đơn của các chặng.</p>
</div>
</div>
);
};
+93
View File
@@ -0,0 +1,93 @@
import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTourStore } from './useTourStore';
import { X, Navigation, Image as ImageIcon } from 'lucide-react';
// Fix lỗi icon mặc định của Leaflet
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
L.Marker.prototype.options.icon = DefaultIcon;
export const ExploreMap = ({ onBack }: { onBack: () => void }) => {
const { publicTours, fetchPublicTours } = useTourStore();
const [userPos, setUserPos] = useState<[number, number]>([10.7769, 106.7009]); // Mặc định TP.HCM
useEffect(() => {
fetchPublicTours();
navigator.geolocation.getCurrentPosition(
(pos) => setUserPos([pos.coords.latitude, pos.coords.longitude]),
() => console.log("Không thể lấy vị trí người dùng")
);
}, []);
return (
<div className="h-screen w-full relative">
{/* Nút quay lại */}
<button
onClick={onBack}
className="absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all"
>
<X className="w-6 h-6 text-gray-800" />
</button>
{/* Header Overlay */}
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block">
<div className="flex items-center gap-2">
<Navigation className="w-4 h-4 text-blue-600" />
<span className="font-bold text-gray-800">Đang khám phá khu vực của bạn</span>
</div>
</div>
<MapContainer center={userPos} zoom={13} className="h-full w-full">
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; OpenStreetMap contributors'
/>
{publicTours.map((tour) => {
const place = tour.legs?.[0]?.places?.[0];
if (!place) return null;
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
return (
<Marker
key={tour.id}
position={[place.latitude, place.longitude]}
icon={L.divIcon({
className: 'custom-bubble',
html: `
<div class="relative group">
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<img src="${tourImage}" class="w-full h-full object-cover" />
</div>
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full p-1 border-2 border-white">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</div>
</div>
`,
iconSize: [48, 48],
})}
>
<Popup className="custom-popup">
<div className="p-1 max-w-[200px]">
<img src={tourImage} className="w-full h-32 object-cover rounded-lg mb-2" />
<h4 className="font-bold text-gray-900 leading-tight mb-1">{tour.title}</h4>
<button className="text-xs font-bold text-blue-600 flex items-center gap-1">
Xem chi tiết hình nh <ImageIcon className="w-3 h-3" />
</button>
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
</div>
);
};
+110
View File
@@ -0,0 +1,110 @@
import React from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle } from 'lucide-react';
import { useTourStore } from './useTourStore';
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
if (!actual) return null;
const diff = differenceInMinutes(parseISO(actual), parseISO(planned));
const isLate = diff > 0;
return (
<div className={`flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`}>
{isLate ? <AlertCircle className="w-3 h-3 mr-1" /> : <CheckCircle2 className="w-3 h-3 mr-1" />}
<span>
{isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút`}
</span>
</div>
);
};
export const ItineraryTimeline = () => {
const { legs } = useTourStore();
const toggleComplete = async (placeId: number) => {
// Gọi API PATCH /api/v1/places/:id để cập nhật trạng thái
console.log("Toggle status for place:", placeId);
};
return (
<div className="max-w-2xl mx-auto p-4 sm:p-6 bg-gray-50 min-h-screen">
<h2 className="text-2xl font-bold text-gray-800 mb-8 px-2">Lộ trình chuyến đi</h2>
<div className="space-y-8">
{legs.map((leg, legIdx) => (
<div key={leg.id} className="relative">
{/* Leg Header */}
<div className="flex items-center mb-4 px-2">
<div className="bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm">
Chặng {leg.sequenceNumber}
</div>
<div className="ml-4 h-[1px] flex-1 bg-gray-200" />
</div>
{/* Vertical Line for the whole leg */}
<div className="absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" />
<div className="space-y-6 ml-2">
{leg.places.map((place) => (
<div key={place.id} className="relative flex group">
{/* Timeline Node */}
<div className="z-10 mt-1.5 mr-4">
<button
onClick={() => toggleComplete(place.id)}
className={`transition-colors duration-200 ${place.isCompleted ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
>
{place.isCompleted ? (
<CheckCircle2 className="w-8 h-8 bg-white rounded-full" />
) : (
<Circle className="w-8 h-8 bg-white rounded-full fill-white" />
)}
</button>
</div>
{/* Card Content */}
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
place.isCompleted ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
}`}>
<div className="flex justify-between items-start">
<div>
<h3 className={`font-semibold text-lg ${place.isCompleted ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
{place.name}
</h3>
<div className="flex items-center text-sm text-gray-500 mt-1">
<MapPin className="w-3 h-3 mr-1" />
<span className="truncate max-w-[200px] sm:max-w-md">{place.address}</span>
</div>
</div>
<div className="text-right flex flex-col items-end">
<div className="flex items-center text-sm font-medium text-blue-600">
<Clock className="w-3 h-3 mr-1" />
{format(parseISO(place.arrivalTime), 'HH:mm')}
</div>
{place.isCompleted && place.completedAt && (
<div className="text-[10px] text-gray-400 mt-1 italic">
Thực tế: {format(parseISO(place.completedAt), 'HH:mm')}
</div>
)}
</div>
</div>
{/* Logic tính toán độ lệch thời gian */}
<TimeVariance planned={place.arrivalTime} actual={place.completedAt} />
</div>
</div>
))}
</div>
{leg.notes && (
<p className="ml-14 mt-4 text-sm text-gray-400 italic">
* {leg.notes}
</p>
)}
</div>
))}
</div>
</div>
);
};
+92
View File
@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { LogIn, Compass, ArrowRight, Camera } from 'lucide-react';
import { LoginModal } from './LoginModal';
interface LandingPageProps {
onContinue?: () => void;
onGoToSignup?: () => void;
}
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
return (
<div className="flex h-screen w-full flex-col md:flex-row overflow-hidden font-sans relative">
{/* Nửa bên trái: Ảnh background mặc định của dự án */}
<div className="hidden md:block md:w-1/2 relative bg-gray-900">
<img
src="https://images.unsplash.com/photo-1503220317375-aaad61436b1b?q=80&w=2070&auto=format&fit=crop"
alt="Travel Planner Background"
className="absolute inset-0 h-full w-full object-cover opacity-70"
/>
{/* Overlay màu xanh nhẹ đặc trưng của dự án */}
<div className="absolute inset-0 bg-gradient-to-t from-blue-900/60 to-transparent" />
<div className="absolute bottom-16 left-16 text-white z-10">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl">
<Compass className="w-8 h-8 text-white" />
</div>
<span className="text-3xl font-black tracking-tighter uppercase">Travel Planner</span>
</div>
<h2 className="text-5xl font-bold leading-tight max-w-lg">
Khám phá thế giới, lưu giữ kỉ niệm.
</h2>
<p className="mt-4 text-blue-100 text-lg opacity-80 max-w-sm">
Hợp tác cùng bạn đ lên lịch trình hoàn hảo nhất cho mọi chuyến đi.
</p>
</div>
</div>
{/* Nửa bên phải: Nội dung chào mừng và Hành động */}
<div className="flex-1 flex flex-col justify-center items-center bg-white p-8 sm:p-12 md:p-20">
<div className="max-w-md w-full space-y-12">
<div className="space-y-6">
<h1 className="text-4xl md:text-5xl font-extrabold text-gray-900 leading-tight">
Chào mừng bạn đến với trang tạo kế hoạch du lịch
</h1>
<p className="text-xl text-gray-600 leading-relaxed">
Đăng nhập đ vào tạo kế hoạch nhân hoặc nhấn nút tiếp tục đ tham quan các hình nh du lịch đưc chia sẻ.
</p>
</div>
<div className="flex flex-col gap-4">
{/* Nút Đăng nhập */}
<button
onClick={() => setIsLoginModalOpen(true)}
className="group flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 px-8 rounded-2xl shadow-xl shadow-blue-200 transition-all active:scale-95"
>
<LogIn className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
Đăng nhập ngay
</button>
{/* Nút Tiếp tục */}
<button
onClick={onContinue}
className="flex items-center justify-center gap-3 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-5 px-8 rounded-2xl border border-gray-200 transition-all active:scale-95"
>
Tiếp tục tham quan nh
<ArrowRight className="w-5 h-5" />
</button>
</div>
<div className="pt-12 flex items-center gap-4 text-sm text-gray-400 border-t border-gray-100">
<div className="flex -space-x-3">
{[1, 2, 3, 4].map(i => (
<img key={i} className="w-10 h-10 rounded-full border-4 border-white" src={`https://i.pravatar.cc/100?img=${i+20}`} alt="user" />
))}
</div>
<p className="font-medium">Tham gia cùng +2,400 người du lịch khác</p>
</div>
</div>
</div>
{/* Login Modal Component */}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
onSwitchToSignup={onGoToSignup}
/>
</div>
);
};
+89
View File
@@ -0,0 +1,89 @@
import React from 'react';
import { X, Mail, Lock, ArrowRight } from 'lucide-react';
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
onSwitchToSignup?: () => void;
}
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitchToSignup }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
{/* Backdrop */}
<div
className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300"
onClick={onClose}
/>
{/* Modal Content */}
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
<div className="p-8 sm:p-10">
<div className="flex justify-between items-start mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900">Đăng nhập</h2>
<p className="text-gray-500 mt-2">Chào mừng bạn quay trở lại!</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
<form className="space-y-6" onSubmit={(e) => e.preventDefault()}>
<div className="space-y-2">
<label className="text-sm font-semibold text-gray-700 ml-1">Email</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="email"
placeholder="name@example.com"
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center px-1">
<label className="text-sm font-semibold text-gray-700">Mật khẩu</label>
<button className="text-xs font-bold text-blue-600 hover:text-blue-700">Quên mật khẩu?</button>
</div>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="password"
placeholder="••••••••"
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<button
type="submit"
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg shadow-blue-200 transition-all active:scale-[0.98]"
>
Đăng nhập
<ArrowRight className="w-5 h-5" />
</button>
</form>
<div className="mt-10 pt-8 border-t border-gray-100 text-center">
<p className="text-gray-500">
Chưa tài khoản?{' '}
<button
onClick={() => { onClose(); onSwitchToSignup?.(); }}
className="font-bold text-blue-600 hover:underline"
>
Tạo tài khoản ngay
</button>
</p>
</div>
</div>
</div>
</div>
);
};
+162
View File
@@ -0,0 +1,162 @@
import React, { useState } from 'react';
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass } from 'lucide-react';
interface SignupPageProps {
onBack: () => void;
onSuccess: () => void;
}
export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: ''
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Mật khẩu xác nhận không khớp');
return;
}
setIsLoading(true);
try {
const response = await fetch('http://localhost:3001/api/v1/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
password: formData.password,
name: formData.name
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng ký thất bại');
}
alert(data.isAdmin ? 'Đăng ký thành công! Bạn là Quản trị viên hệ thống.' : 'Đăng ký tài khoản thành công!');
onSuccess();
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex h-screen w-full overflow-hidden font-sans bg-white">
{/* Nửa bên trái: Hình ảnh decor (Đồng bộ với LandingPage) */}
<div className="hidden lg:block lg:w-1/2 relative bg-blue-900">
<img
src="https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
alt="Travel background"
className="absolute inset-0 h-full w-full object-cover opacity-50"
/>
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/40 to-transparent" />
<div className="absolute top-12 left-12 flex items-center gap-2 text-white z-10">
<Compass className="w-8 h-8" />
<span className="text-2xl font-black uppercase tracking-tighter">Travel Planner</span>
</div>
<div className="absolute bottom-20 left-12 text-white z-10 max-w-md">
<h2 className="text-4xl font-bold mb-4">Bắt đu hành trình của riêng bạn.</h2>
<p className="text-blue-100 opacity-80">Tạo tài khoản đ lưu lại những kế hoạch du lịch tuyệt vời nhất cùng bạn .</p>
</div>
</div>
{/* Nửa bên phải: Form đăng ký */}
<div className="flex-1 flex flex-col justify-center px-8 sm:px-16 lg:px-24 py-12 overflow-y-auto">
<div className="max-w-md w-full mx-auto">
<button onClick={onBack} className="flex items-center text-gray-400 hover:text-blue-600 mb-8 transition-colors font-medium">
<ChevronLeft className="w-5 h-5" /> Quay lại
</button>
<div className="mb-10">
<h1 className="text-3xl font-extrabold text-gray-900">Tạo tài khoản mới</h1>
<p className="text-gray-500 mt-2 font-medium">Khám phá các tính năng lập kế hoạch chuyên nghiệp.</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
{error}
</div>
)}
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Họ tên</label>
<div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="text"
placeholder="Nguyễn Văn A"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Email</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="email"
placeholder="email@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
<input
required
type="password"
placeholder="••••••••"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Xác nhận</label>
<input
required
type="password"
placeholder="••••••••"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<button
disabled={isLoading}
type="submit"
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 rounded-2xl shadow-xl shadow-blue-100 transition-all active:scale-[0.98] disabled:opacity-50 mt-4"
>
{isLoading ? 'Đang xử lý...' : 'Tạo tài khoản'}
{!isLoading && <ArrowRight className="w-5 h-5" />}
</button>
</form>
</div>
</div>
</div>
);
};
+137
View File
@@ -0,0 +1,137 @@
import React, { useState, useEffect } from 'react';
import { ItineraryTimeline } from './ItineraryTimeline';
import { ExpenseManager } from './ExpenseManager';
import { useTourStore } from './useTourStore';
import {
Map as MapIcon,
Wallet,
Image as ImageIcon,
Calendar,
Users,
ChevronLeft
} from 'lucide-react';
export const TourDetailPage = () => {
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo'>('plan');
const { currentTour, fetchTour, userRole } = useTourStore();
useEffect(() => {
// Gọi ID 1 (Dữ liệu từ file seed) để kiểm tra
fetchTour(1);
}, [fetchTour]);
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
const tabs = [
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: ['OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY'].includes(userRole || '') },
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'EDITOR'].includes(userRole || '') },
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
].filter(t => t.visible);
// Tự động chuyển tab nếu người dùng không có quyền xem 'plan'
useEffect(() => {
if (userRole === 'MEMBER_PHOTO_ONLY') {
setActiveTab('photo');
}
}, [userRole]);
// Mock dữ liệu header (Trong thực tế sẽ lấy từ currentTour)
const tourInfo = {
title: currentTour?.title || "Hành trình khám phá TP.HCM",
date: "20 - 21 Tháng 11, 2023",
members: 5,
budget: "2.500.000 VND"
};
return (
<div className="min-h-screen bg-white">
{/* Top Navigation Bar */}
<div className="sticky top-0 z-30 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<ChevronLeft className="w-6 h-6 text-gray-600" />
</button>
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
{tourInfo.title}
</h1>
<div className="w-10" /> {/* Spacer */}
</div>
{/* Tour Header Info */}
<div className="bg-blue-600 text-white p-6 pb-20">
<div className="max-w-2xl mx-auto space-y-4">
<div className="flex flex-wrap gap-4 text-sm opacity-90">
<div className="flex items-center">
<Calendar className="w-4 h-4 mr-1.5" />
{tourInfo.date}
</div>
<div className="flex items-center">
<Users className="w-4 h-4 mr-1.5" />
{tourInfo.members} thành viên
</div>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{tourInfo.budget}</span>
<span className="text-blue-100 text-sm">dự kiến</span>
</div>
</div>
</div>
{/* Main Content Area */}
<div className="max-w-2xl mx-auto -mt-12 px-4 pb-24">
{/* Tab Switcher */}
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-1 flex mb-6">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === tab.id
? 'bg-blue-50 text-blue-600 shadow-sm'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'animate-pulse' : ''}`} />
{tab.label}
</button>
))}
</div>
{/* Tab Panels */}
<div className="transition-opacity duration-300">
{activeTab === 'plan' && (
<div className="animate-in fade-in slide-in-from-bottom-4">
<ItineraryTimeline />
</div>
)}
{activeTab === 'expense' && (
<div className="animate-in fade-in slide-in-from-bottom-4">
<ExpenseManager />
</div>
)}
{activeTab === 'photo' && (
<div className="grid grid-cols-3 gap-2 animate-in fade-in">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="aspect-square bg-gray-100 rounded-lg overflow-hidden relative group">
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<img
src={`https://picsum.photos/seed/${i + 10}/400/400`}
alt="Tour photo"
className="w-full h-full object-cover"
/>
</div>
))}
</div>
)}
</div>
</div>
{/* Floating Action Button (Mobile) */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
<button className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
</button>
</div>
</div>
);
};
+3
View File
@@ -0,0 +1,3 @@
import React from 'react';
declare const App: () => React.JSX.Element;
export default App;
Vendored
+15
View File
@@ -0,0 +1,15 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const jsx_runtime_1 = require("react/jsx-runtime");
const react_1 = require("react");
const LandingPage_1 = require("./LandingPage");
const TourDetailPage_1 = require("./TourDetailPage");
const App = () => {
const [isStarted, setIsStarted] = (0, react_1.useState)(false);
const handleContinue = () => {
setIsStarted(true);
};
return ((0, jsx_runtime_1.jsx)("div", { className: "app-container", children: !isStarted ? ((0, jsx_runtime_1.jsx)(LandingPage_1.LandingPage, { onContinue: handleContinue })) : ((0, jsx_runtime_1.jsx)(TourDetailPage_1.TourDetailPage, {})) }));
};
exports.default = App;
//# sourceMappingURL=App.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"App.js","sourceRoot":"","sources":["../App.tsx"],"names":[],"mappings":";;;AAAA,iCAAwC;AACxC,+CAA4C;AAC5C,qDAAkD;AAElD,MAAM,GAAG,GAAG,GAAG,EAAE;IAGf,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,IAAA,gBAAQ,EAAC,KAAK,CAAC,CAAC;IAElD,MAAM,cAAc,GAAG,GAAG,EAAE;QAC1B,YAAY,CAAC,IAAI,CAAC,CAAC;IACrB,CAAC,CAAC;IAEF,OAAO,CACL,gCAAK,SAAS,EAAC,eAAe,YAC3B,CAAC,SAAS,CAAC,CAAC,CAAC,CACZ,uBAAC,yBAAW,IAAC,UAAU,EAAE,cAAc,GAAI,CAC5C,CAAC,CAAC,CAAC,CACF,uBAAC,+BAAc,KAAG,CACnB,GACG,CACP,CAAC;AACJ,CAAC,CAAC;AAEF,kBAAe,GAAG,CAAC"}
+2
View File
@@ -0,0 +1,2 @@
import React from 'react';
export declare const ExpenseManager: () => React.JSX.Element;
+28
View File
@@ -0,0 +1,28 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ExpenseManager = void 0;
const jsx_runtime_1 = require("react/jsx-runtime");
const react_1 = require("react");
const lucide_react_1 = require("lucide-react");
const useTourStore_1 = require("./useTourStore");
const ExpenseManager = () => {
const { legs } = (0, useTourStore_1.useTourStore)();
const [adults, setAdults] = (0, react_1.useState)(2);
const [children, setChildren] = (0, react_1.useState)(1);
const [discount, setDiscount] = (0, react_1.useState)(30);
const totals = (0, react_1.useMemo)(() => {
const totalAmount = legs.reduce((acc, leg) => acc + leg.expenses.reduce((lAcc, exp) => lAcc + Number(exp.amount), 0), 0);
const childRateFactor = 1 - (discount / 100);
const weightedCount = adults + (children * childRateFactor);
const adultPrice = totalAmount / weightedCount;
const childPrice = adultPrice * childRateFactor;
return {
total: totalAmount,
adultPrice: Math.round(adultPrice),
childPrice: Math.round(childPrice)
};
}, [legs, adults, children, discount]);
return ((0, jsx_runtime_1.jsxs)("div", { className: "space-y-6 animate-in fade-in slide-in-from-bottom-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "bg-white p-6 rounded-2xl border border-gray-100 shadow-sm", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 mb-6 text-blue-600", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Users, { className: "w-5 h-5" }), (0, jsx_runtime_1.jsx)("h3", { className: "font-bold", children: "C\u1EA5u h\u00ECnh th\u00E0nh vi\u00EAn" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-3 gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { className: "text-xs text-gray-400 block mb-1", children: "Ng\u01B0\u1EDDi l\u1EDBn" }), (0, jsx_runtime_1.jsx)("input", { type: "number", value: adults, onChange: e => setAdults(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { className: "text-xs text-gray-400 block mb-1", children: "Tr\u1EBB em" }), (0, jsx_runtime_1.jsx)("input", { type: "number", value: children, onChange: e => setChildren(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { className: "text-xs text-gray-400 block mb-1", children: "Gi\u1EA3m tr\u1EBB em (%)" }), (0, jsx_runtime_1.jsx)("input", { type: "number", value: discount, onChange: e => setDiscount(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between items-start mb-8", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "text-blue-100 text-sm", children: "T\u1ED5ng chi ph\u00ED chuy\u1EBFn \u0111i" }), (0, jsx_runtime_1.jsxs)("h2", { className: "text-3xl font-bold mt-1", children: [totals.total.toLocaleString(), " VND"] })] }), (0, jsx_runtime_1.jsx)(lucide_react_1.Wallet, { className: "w-8 h-8 opacity-20" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-2 gap-4 border-t border-blue-500 pt-6", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "text-blue-100 text-xs uppercase tracking-wider font-semibold", children: "M\u1ED7i ng\u01B0\u1EDDi l\u1EDBn" }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xl font-bold", children: [totals.adultPrice.toLocaleString(), "\u0111"] })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("p", { className: "text-blue-100 text-xs uppercase tracking-wider font-semibold", children: ["M\u1ED7i tr\u1EBB em (-", discount, "%)"] }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xl font-bold", children: [totals.childPrice.toLocaleString(), "\u0111"] })] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 text-gray-400 text-xs px-2", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Info, { className: "w-4 h-4" }), (0, jsx_runtime_1.jsx)("p", { children: "Chi ph\u00ED \u0111\u01B0\u1EE3c t\u1EF1 \u0111\u1ED9ng t\u00EDnh to\u00E1n d\u1EF1a tr\u00EAn h\u00F3a \u0111\u01A1n c\u1EE7a c\u00E1c ch\u1EB7ng." })] })] }));
};
exports.ExpenseManager = ExpenseManager;
//# sourceMappingURL=ExpenseManager.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"ExpenseManager.js","sourceRoot":"","sources":["../ExpenseManager.tsx"],"names":[],"mappings":"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"}
+2
View File
@@ -0,0 +1,2 @@
import React from 'react';
export declare const ItineraryTimeline: () => React.JSX.Element;
+23
View File
@@ -0,0 +1,23 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ItineraryTimeline = void 0;
const jsx_runtime_1 = require("react/jsx-runtime");
const date_fns_1 = require("date-fns");
const lucide_react_1 = require("lucide-react");
const useTourStore_1 = require("./useTourStore");
const TimeVariance = ({ planned, actual }) => {
if (!actual)
return null;
const diff = (0, date_fns_1.differenceInMinutes)((0, date_fns_1.parseISO)(actual), (0, date_fns_1.parseISO)(planned));
const isLate = diff > 0;
return ((0, jsx_runtime_1.jsxs)("div", { className: `flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`, children: [isLate ? (0, jsx_runtime_1.jsx)(lucide_react_1.AlertCircle, { className: "w-3 h-3 mr-1" }) : (0, jsx_runtime_1.jsx)(lucide_react_1.CheckCircle2, { className: "w-3 h-3 mr-1" }), (0, jsx_runtime_1.jsx)("span", { children: isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút` })] }));
};
const ItineraryTimeline = () => {
const { legs } = (0, useTourStore_1.useTourStore)();
const toggleComplete = async (placeId) => {
console.log("Toggle status for place:", placeId);
};
return ((0, jsx_runtime_1.jsxs)("div", { className: "max-w-2xl mx-auto p-4 sm:p-6 bg-gray-50 min-h-screen", children: [(0, jsx_runtime_1.jsx)("h2", { className: "text-2xl font-bold text-gray-800 mb-8 px-2", children: "L\u1ED9 tr\u00ECnh chuy\u1EBFn \u0111i" }), (0, jsx_runtime_1.jsx)("div", { className: "space-y-8", children: legs.map((leg, legIdx) => ((0, jsx_runtime_1.jsxs)("div", { className: "relative", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center mb-4 px-2", children: [(0, jsx_runtime_1.jsxs)("div", { className: "bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm", children: ["Ch\u1EB7ng ", leg.sequenceNumber] }), (0, jsx_runtime_1.jsx)("div", { className: "ml-4 h-[1px] flex-1 bg-gray-200" })] }), (0, jsx_runtime_1.jsx)("div", { className: "absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" }), (0, jsx_runtime_1.jsx)("div", { className: "space-y-6 ml-2", children: leg.places.map((place) => ((0, jsx_runtime_1.jsxs)("div", { className: "relative flex group", children: [(0, jsx_runtime_1.jsx)("div", { className: "z-10 mt-1.5 mr-4", children: (0, jsx_runtime_1.jsx)("button", { onClick: () => toggleComplete(place.id), className: `transition-colors duration-200 ${place.isCompleted ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: place.isCompleted ? ((0, jsx_runtime_1.jsx)(lucide_react_1.CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), (0, jsx_runtime_1.jsxs)("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${place.isCompleted ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between items-start", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h3", { className: `font-semibold text-lg ${place.isCompleted ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: place.name }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.MapPin, { className: "w-3 h-3 mr-1" }), (0, jsx_runtime_1.jsx)("span", { className: "truncate max-w-[200px] sm:max-w-md", children: place.address })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "text-right flex flex-col items-end", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Clock, { className: "w-3 h-3 mr-1" }), (0, date_fns_1.format)((0, date_fns_1.parseISO)(place.arrivalTime), 'HH:mm')] }), place.isCompleted && place.completedAt && ((0, jsx_runtime_1.jsxs)("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", (0, date_fns_1.format)((0, date_fns_1.parseISO)(place.completedAt), 'HH:mm')] }))] })] }), (0, jsx_runtime_1.jsx)(TimeVariance, { planned: place.arrivalTime, actual: place.completedAt })] })] }, place.id))) }), leg.notes && ((0, jsx_runtime_1.jsxs)("p", { className: "ml-14 mt-4 text-sm text-gray-400 italic", children: ["* ", leg.notes] }))] }, leg.id))) })] }));
};
exports.ItineraryTimeline = ItineraryTimeline;
//# sourceMappingURL=ItineraryTimeline.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"ItineraryTimeline.js","sourceRoot":"","sources":["../ItineraryTimeline.tsx"],"names":[],"mappings":"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"}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
interface LandingPageProps {
onContinue?: () => void;
}
export declare const LandingPage: React.FC<LandingPageProps>;
export {};
+13
View File
@@ -0,0 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LandingPage = void 0;
const jsx_runtime_1 = require("react/jsx-runtime");
const react_1 = require("react");
const lucide_react_1 = require("lucide-react");
const LoginModal_1 = require("./LoginModal");
const LandingPage = ({ onContinue }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = (0, react_1.useState)(false);
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex h-screen w-full flex-col md:flex-row overflow-hidden font-sans relative", children: [(0, jsx_runtime_1.jsxs)("div", { className: "hidden md:block md:w-1/2 relative bg-gray-900", children: [(0, jsx_runtime_1.jsx)("img", { src: "https://images.unsplash.com/photo-1503220317375-aaad61436b1b?q=80&w=2070&auto=format&fit=crop", alt: "Travel Planner Background", className: "absolute inset-0 h-full w-full object-cover opacity-70" }), (0, jsx_runtime_1.jsx)("div", { className: "absolute inset-0 bg-gradient-to-t from-blue-900/60 to-transparent" }), (0, jsx_runtime_1.jsxs)("div", { className: "absolute bottom-16 left-16 text-white z-10", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3 mb-6", children: [(0, jsx_runtime_1.jsx)("div", { className: "p-3 bg-white/20 backdrop-blur-md rounded-2xl", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Compass, { className: "w-8 h-8 text-white" }) }), (0, jsx_runtime_1.jsx)("span", { className: "text-3xl font-black tracking-tighter uppercase", children: "Travel Planner" })] }), (0, jsx_runtime_1.jsx)("h2", { className: "text-5xl font-bold leading-tight max-w-lg", children: "Kh\u00E1m ph\u00E1 th\u1EBF gi\u1EDBi, l\u01B0u gi\u1EEF k\u1EC9 ni\u1EC7m." }), (0, jsx_runtime_1.jsx)("p", { className: "mt-4 text-blue-100 text-lg opacity-80 max-w-sm", children: "H\u1EE3p t\u00E1c c\u00F9ng b\u1EA1n b\u00E8 \u0111\u1EC3 l\u00EAn l\u1ECBch tr\u00ECnh ho\u00E0n h\u1EA3o nh\u1EA5t cho m\u1ECDi chuy\u1EBFn \u0111i." })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "flex-1 flex flex-col justify-center items-center bg-white p-8 sm:p-12 md:p-20", children: (0, jsx_runtime_1.jsxs)("div", { className: "max-w-md w-full space-y-12", children: [(0, jsx_runtime_1.jsxs)("div", { className: "space-y-6", children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-4xl md:text-5xl font-extrabold text-gray-900 leading-tight", children: "Ch\u00E0o m\u1EEBng b\u1EA1n \u0111\u1EBFn v\u1EDBi trang t\u1EA1o k\u1EBF ho\u1EA1ch du l\u1ECBch" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xl text-gray-600 leading-relaxed", children: "\u0110\u0103ng nh\u1EADp \u0111\u1EC3 v\u00E0o t\u1EA1o k\u1EBF ho\u1EA1ch c\u00E1 nh\u00E2n ho\u1EB7c nh\u1EA5n n\u00FAt ti\u1EBFp t\u1EE5c \u0111\u1EC3 tham quan c\u00E1c h\u00ECnh \u1EA3nh du l\u1ECBch \u0111\u01B0\u1EE3c chia s\u1EBB." })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)("button", { onClick: () => setIsLoginModalOpen(true), className: "group flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 px-8 rounded-2xl shadow-xl shadow-blue-200 transition-all active:scale-95", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.LogIn, { className: "w-5 h-5 group-hover:translate-x-1 transition-transform" }), "\u0110\u0103ng nh\u1EADp ngay"] }), (0, jsx_runtime_1.jsxs)("button", { onClick: onContinue, className: "flex items-center justify-center gap-3 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-5 px-8 rounded-2xl border border-gray-200 transition-all active:scale-95", children: ["Ti\u1EBFp t\u1EE5c tham quan \u1EA3nh", (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowRight, { className: "w-5 h-5" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "pt-12 flex items-center gap-4 text-sm text-gray-400 border-t border-gray-100", children: [(0, jsx_runtime_1.jsx)("div", { className: "flex -space-x-3", children: [1, 2, 3, 4].map(i => ((0, jsx_runtime_1.jsx)("img", { className: "w-10 h-10 rounded-full border-4 border-white", src: `https://i.pravatar.cc/100?img=${i + 20}`, alt: "user" }, i))) }), (0, jsx_runtime_1.jsx)("p", { className: "font-medium", children: "Tham gia c\u00F9ng +2,400 ng\u01B0\u1EDDi du l\u1ECBch kh\u00E1c" })] })] }) }), (0, jsx_runtime_1.jsx)(LoginModal_1.LoginModal, { isOpen: isLoginModalOpen, onClose: () => setIsLoginModalOpen(false) })] }));
};
exports.LandingPage = LandingPage;
//# sourceMappingURL=LandingPage.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"LandingPage.js","sourceRoot":"","sources":["../LandingPage.tsx"],"names":[],"mappings":";;;;AAAA,iCAAwC;AACxC,+CAAkE;AAClE,6CAA0C;AAMnC,MAAM,WAAW,GAA+B,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE;IACxE,MAAM,CAAC,gBAAgB,EAAE,mBAAmB,CAAC,GAAG,IAAA,gBAAQ,EAAC,KAAK,CAAC,CAAC;IAEhE,OAAO,CACL,iCAAK,SAAS,EAAC,8EAA8E,aAE3F,iCAAK,SAAS,EAAC,+CAA+C,aAC5D,gCACE,GAAG,EAAC,+FAA+F,EACnG,GAAG,EAAC,2BAA2B,EAC/B,SAAS,EAAC,wDAAwD,GAClE,EAEF,gCAAK,SAAS,EAAC,mEAAmE,GAAG,EAErF,iCAAK,SAAS,EAAC,4CAA4C,aACzD,iCAAK,SAAS,EAAC,8BAA8B,aAC3C,gCAAK,SAAS,EAAC,8CAA8C,YAC3D,uBAAC,sBAAO,IAAC,SAAS,EAAC,oBAAoB,GAAG,GACtC,EACN,iCAAM,SAAS,EAAC,gDAAgD,+BAAsB,IAClF,EACN,+BAAI,SAAS,EAAC,2CAA2C,4FAEpD,EACL,8BAAG,SAAS,EAAC,gDAAgD,uKAEzD,IACA,IACF,EAGN,gCAAK,SAAS,EAAC,+EAA+E,YAC5F,iCAAK,SAAS,EAAC,4BAA4B,aACzC,iCAAK,SAAS,EAAC,WAAW,aACxB,+BAAI,SAAS,EAAC,iEAAiE,mHAE1E,EACL,8BAAG,SAAS,EAAC,uCAAuC,+PAEhD,IACA,EAEN,iCAAK,SAAS,EAAC,qBAAqB,aAElC,oCACE,OAAO,EAAE,GAAG,EAAE,CAAC,mBAAmB,CAAC,IAAI,CAAC,EACxC,SAAS,EAAC,gLAAgL,aAE1L,uBAAC,oBAAK,IAAC,SAAS,EAAC,wDAAwD,GAAG,qCAErE,EAGT,oCACE,OAAO,EAAE,UAAU,EACnB,SAAS,EAAC,yKAAyK,sDAGnL,uBAAC,yBAAU,IAAC,SAAS,EAAC,SAAS,GAAG,IAC3B,IACL,EAEN,iCAAK,SAAS,EAAC,8EAA8E,aAC3F,gCAAK,SAAS,EAAC,iBAAiB,YAC7B,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CACrB,gCAAa,SAAS,EAAC,8CAA8C,EAAC,GAAG,EAAE,iCAAiC,CAAC,GAAC,EAAE,EAAE,EAAE,GAAG,EAAC,MAAM,IAApH,CAAC,CAAsH,CAClI,CAAC,GACE,EACN,8BAAG,SAAS,EAAC,aAAa,iFAA4C,IAClE,IACF,GACF,EAGN,uBAAC,uBAAU,IAAC,MAAM,EAAE,gBAAgB,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,mBAAmB,CAAC,KAAK,CAAC,GAAI,IAC/E,CACP,CAAC;AACJ,CAAC,CAAC;AA9EW,QAAA,WAAW,eA8EtB"}
+7
View File
@@ -0,0 +1,7 @@
import React from 'react';
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
}
export declare const LoginModal: React.FC<LoginModalProps>;
export {};
+12
View File
@@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LoginModal = void 0;
const jsx_runtime_1 = require("react/jsx-runtime");
const lucide_react_1 = require("lucide-react");
const LoginModal = ({ isOpen, onClose }) => {
if (!isOpen)
return null;
return ((0, jsx_runtime_1.jsxs)("div", { className: "fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6", children: [(0, jsx_runtime_1.jsx)("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300", onClick: onClose }), (0, jsx_runtime_1.jsx)("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300", children: (0, jsx_runtime_1.jsxs)("div", { className: "p-8 sm:p-10", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between items-start mb-8", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h2", { className: "text-3xl font-bold text-gray-900", children: "\u0110\u0103ng nh\u1EADp" }), (0, jsx_runtime_1.jsx)("p", { className: "text-gray-500 mt-2", children: "Ch\u00E0o m\u1EEBng b\u1EA1n quay tr\u1EDF l\u1EA1i!" })] }), (0, jsx_runtime_1.jsx)("button", { onClick: onClose, className: "p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600", children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "w-6 h-6" }) })] }), (0, jsx_runtime_1.jsxs)("form", { className: "space-y-6", onSubmit: (e) => e.preventDefault(), children: [(0, jsx_runtime_1.jsxs)("div", { className: "space-y-2", children: [(0, jsx_runtime_1.jsx)("label", { className: "text-sm font-semibold text-gray-700 ml-1", children: "Email" }), (0, jsx_runtime_1.jsxs)("div", { className: "relative group", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Mail, { className: "absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" }), (0, jsx_runtime_1.jsx)("input", { type: "email", placeholder: "name@example.com", className: "w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "space-y-2", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between items-center px-1", children: [(0, jsx_runtime_1.jsx)("label", { className: "text-sm font-semibold text-gray-700", children: "M\u1EADt kh\u1EA9u" }), (0, jsx_runtime_1.jsx)("button", { className: "text-xs font-bold text-blue-600 hover:text-blue-700", children: "Qu\u00EAn m\u1EADt kh\u1EA9u?" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "relative group", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Lock, { className: "absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" }), (0, jsx_runtime_1.jsx)("input", { type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", className: "w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" })] })] }), (0, jsx_runtime_1.jsxs)("button", { type: "submit", className: "w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg shadow-blue-200 transition-all active:scale-[0.98]", children: ["\u0110\u0103ng nh\u1EADp", (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowRight, { className: "w-5 h-5" })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "mt-10 pt-8 border-t border-gray-100 text-center", children: (0, jsx_runtime_1.jsxs)("p", { className: "text-gray-500", children: ["Ch\u01B0a c\u00F3 t\u00E0i kho\u1EA3n?", ' ', (0, jsx_runtime_1.jsx)("button", { className: "font-bold text-blue-600 hover:underline", children: "T\u1EA1o t\u00E0i kho\u1EA3n ngay" })] }) })] }) })] }));
};
exports.LoginModal = LoginModal;
//# sourceMappingURL=LoginModal.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"LoginModal.js","sourceRoot":"","sources":["../LoginModal.tsx"],"names":[],"mappings":";;;;AACA,+CAAyD;AAOlD,MAAM,UAAU,GAA8B,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;IAC3E,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IAEzB,OAAO,CACL,iCAAK,SAAS,EAAC,gEAAgE,aAE7E,gCACE,SAAS,EAAC,kFAAkF,EAC5F,OAAO,EAAE,OAAO,GAChB,EAGF,gCAAK,SAAS,EAAC,6GAA6G,YAC1H,iCAAK,SAAS,EAAC,aAAa,aAC1B,iCAAK,SAAS,EAAC,uCAAuC,aACpD,4CACE,+BAAI,SAAS,EAAC,kCAAkC,yCAAe,EAC/D,8BAAG,SAAS,EAAC,oBAAoB,qEAAgC,IAC7D,EACN,mCACE,OAAO,EAAE,OAAO,EAChB,SAAS,EAAC,wFAAwF,YAElG,uBAAC,gBAAC,IAAC,SAAS,EAAC,SAAS,GAAG,GAClB,IACL,EAEN,kCAAM,SAAS,EAAC,WAAW,EAAC,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,cAAc,EAAE,aAC7D,iCAAK,SAAS,EAAC,WAAW,aACxB,kCAAO,SAAS,EAAC,0CAA0C,sBAAc,EACzE,iCAAK,SAAS,EAAC,gBAAgB,aAC7B,uBAAC,mBAAI,IAAC,SAAS,EAAC,mHAAmH,GAAG,EACtI,kCACE,IAAI,EAAC,OAAO,EACZ,WAAW,EAAC,kBAAkB,EAC9B,SAAS,EAAC,kJAAkJ,GAC5J,IACE,IACF,EAEN,iCAAK,SAAS,EAAC,WAAW,aACxB,iCAAK,SAAS,EAAC,wCAAwC,aACrD,kCAAO,SAAS,EAAC,qCAAqC,mCAAiB,EACvE,mCAAQ,SAAS,EAAC,qDAAqD,8CAAwB,IAC3F,EACN,iCAAK,SAAS,EAAC,gBAAgB,aAC7B,uBAAC,mBAAI,IAAC,SAAS,EAAC,mHAAmH,GAAG,EACtI,kCACE,IAAI,EAAC,UAAU,EACf,WAAW,EAAC,kDAAU,EACtB,SAAS,EAAC,kJAAkJ,GAC5J,IACE,IACF,EAEN,oCACE,IAAI,EAAC,QAAQ,EACb,SAAS,EAAC,gLAAgL,yCAG1L,uBAAC,yBAAU,IAAC,SAAS,EAAC,SAAS,GAAG,IAC3B,IACJ,EAEP,gCAAK,SAAS,EAAC,iDAAiD,YAC9D,+BAAG,SAAS,EAAC,eAAe,uDACP,GAAG,EACtB,mCAAQ,SAAS,EAAC,yCAAyC,kDAA4B,IACrF,GACA,IACF,GACF,IACF,CACP,CAAC;AACJ,CAAC,CAAC;AA1EW,QAAA,UAAU,cA0ErB"}
+2
View File
@@ -0,0 +1,2 @@
import React from 'react';
export declare const TourDetailPage: () => React.JSX.Element;
+37
View File
@@ -0,0 +1,37 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TourDetailPage = void 0;
const jsx_runtime_1 = require("react/jsx-runtime");
const react_1 = require("react");
const ItineraryTimeline_1 = require("./ItineraryTimeline");
const ExpenseManager_1 = require("./ExpenseManager");
const useTourStore_1 = require("./useTourStore");
const lucide_react_1 = require("lucide-react");
const TourDetailPage = () => {
const [activeTab, setActiveTab] = (0, react_1.useState)('plan');
const { currentTour, fetchTour, userRole } = (0, useTourStore_1.useTourStore)();
(0, react_1.useEffect)(() => {
fetchTour(1);
}, [fetchTour]);
const tabs = [
{ id: 'plan', label: 'Lộ trình', icon: lucide_react_1.Map, visible: ['OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY'].includes(userRole || '') },
{ id: 'expense', label: 'Chi phí', icon: lucide_react_1.Wallet, visible: ['OWNER', 'EDITOR'].includes(userRole || '') },
{ id: 'photo', label: 'Ảnh', icon: lucide_react_1.Image, visible: true },
].filter(t => t.visible);
(0, react_1.useEffect)(() => {
if (userRole === 'MEMBER_PHOTO_ONLY') {
setActiveTab('photo');
}
}, [userRole]);
const tourInfo = {
title: currentTour?.title || "Hành trình khám phá TP.HCM",
date: "20 - 21 Tháng 11, 2023",
members: 5,
budget: "2.500.000 VND"
};
return ((0, jsx_runtime_1.jsxs)("div", { className: "min-h-screen bg-white", children: [(0, jsx_runtime_1.jsxs)("div", { className: "sticky top-0 z-30 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between", children: [(0, jsx_runtime_1.jsx)("button", { className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronLeft, { className: "w-6 h-6 text-gray-600" }) }), (0, jsx_runtime_1.jsx)("h1", { className: "text-lg font-bold text-gray-800 truncate px-4", children: tourInfo.title }), (0, jsx_runtime_1.jsx)("div", { className: "w-10" }), " "] }), (0, jsx_runtime_1.jsx)("div", { className: "bg-blue-600 text-white p-6 pb-20", children: (0, jsx_runtime_1.jsxs)("div", { className: "max-w-2xl mx-auto space-y-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap gap-4 text-sm opacity-90", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Calendar, { className: "w-4 h-4 mr-1.5" }), tourInfo.date] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Users, { className: "w-4 h-4 mr-1.5" }), tourInfo.members, " th\u00E0nh vi\u00EAn"] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-baseline gap-2", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-3xl font-bold", children: tourInfo.budget }), (0, jsx_runtime_1.jsx)("span", { className: "text-blue-100 text-sm", children: "d\u1EF1 ki\u1EBFn" })] })] }) }), (0, jsx_runtime_1.jsxs)("div", { className: "max-w-2xl mx-auto -mt-12 px-4 pb-24", children: [(0, jsx_runtime_1.jsx)("div", { className: "bg-white rounded-2xl shadow-xl border border-gray-100 p-1 flex mb-6", children: tabs.map((tab) => ((0, jsx_runtime_1.jsxs)("button", { onClick: () => setActiveTab(tab.id), className: `flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${activeTab === tab.id
? 'bg-blue-50 text-blue-600 shadow-sm'
: 'text-gray-400 hover:text-gray-600'}`, children: [(0, jsx_runtime_1.jsx)(tab.icon, { className: `w-4 h-4 mr-2 ${activeTab === tab.id ? 'animate-pulse' : ''}` }), tab.label] }, tab.id))) }), (0, jsx_runtime_1.jsxs)("div", { className: "transition-opacity duration-300", children: [activeTab === 'plan' && ((0, jsx_runtime_1.jsx)("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: (0, jsx_runtime_1.jsx)(ItineraryTimeline_1.ItineraryTimeline, {}) })), activeTab === 'expense' && ((0, jsx_runtime_1.jsx)("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: (0, jsx_runtime_1.jsx)(ExpenseManager_1.ExpenseManager, {}) })), activeTab === 'photo' && ((0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-3 gap-2 animate-in fade-in", children: [1, 2, 3, 4, 5, 6].map((i) => ((0, jsx_runtime_1.jsxs)("div", { className: "aspect-square bg-gray-100 rounded-lg overflow-hidden relative group", children: [(0, jsx_runtime_1.jsx)("div", { className: "absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" }), (0, jsx_runtime_1.jsx)("img", { src: `https://picsum.photos/seed/${i + 10}/400/400`, alt: "Tour photo", className: "w-full h-full object-cover" })] }, i))) }))] })] }), (0, jsx_runtime_1.jsx)("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: (0, jsx_runtime_1.jsx)("button", { className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) })] }));
};
exports.TourDetailPage = TourDetailPage;
//# sourceMappingURL=TourDetailPage.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"TourDetailPage.js","sourceRoot":"","sources":["../TourDetailPage.tsx"],"names":[],"mappings":"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"}
+11
View File
@@ -0,0 +1,11 @@
export declare class ExpenseEngine {
calculateSplit(totalCost: number, adultCount: number, childCount: number, childDiscountPercent: number): {
targetTotal: number;
actualTotal: number;
perAdult: number;
perChild: number;
roundingDifference: number;
adultsTotal: number;
childrenTotal: number;
};
}
+38
View File
@@ -0,0 +1,38 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ExpenseEngine = void 0;
const common_1 = require("@nestjs/common");
class ExpenseEngine {
calculateSplit(totalCost, adultCount, childCount, childDiscountPercent) {
if (totalCost < 0 || adultCount < 0 || childCount < 0 || childDiscountPercent < 0) {
throw new common_1.BadRequestException('Dữ liệu đầu vào không được là số âm.');
}
if (childDiscountPercent > 100) {
throw new common_1.BadRequestException('Phần trăm giảm giá không thể vượt quá 100%.');
}
if (adultCount + childCount === 0) {
throw new common_1.BadRequestException('Số lượng người tham gia phải lớn hơn 0.');
}
const discountRate = childDiscountPercent / 100;
const childRateFactor = 1 - discountRate;
const weightedParticipantCount = adultCount + (childCount * childRateFactor);
const adultPriceRaw = totalCost / weightedParticipantCount;
const childPriceRaw = adultPriceRaw * childRateFactor;
const perAdult = Math.round(adultPriceRaw);
const perChild = Math.round(childPriceRaw);
const adultsTotal = perAdult * adultCount;
const childrenTotal = perChild * childCount;
const actualSum = adultsTotal + childrenTotal;
return {
targetTotal: totalCost,
actualTotal: actualSum,
perAdult,
perChild,
roundingDifference: totalCost - actualSum,
adultsTotal,
childrenTotal,
};
}
}
exports.ExpenseEngine = ExpenseEngine;
//# sourceMappingURL=expense-engine.service.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"expense-engine.service.js","sourceRoot":"","sources":["../expense-engine.service.ts"],"names":[],"mappings":";;;AAAA,2CAAqD;AAMrD,MAAa,aAAa;IAQxB,cAAc,CACZ,SAAiB,EACjB,UAAkB,EAClB,UAAkB,EAClB,oBAA4B;QAG5B,IAAI,SAAS,GAAG,CAAC,IAAI,UAAU,GAAG,CAAC,IAAI,UAAU,GAAG,CAAC,IAAI,oBAAoB,GAAG,CAAC,EAAE,CAAC;YAClF,MAAM,IAAI,4BAAmB,CAAC,sCAAsC,CAAC,CAAC;QACxE,CAAC;QAED,IAAI,oBAAoB,GAAG,GAAG,EAAE,CAAC;YAC/B,MAAM,IAAI,4BAAmB,CAAC,6CAA6C,CAAC,CAAC;QAC/E,CAAC;QAGD,IAAI,UAAU,GAAG,UAAU,KAAK,CAAC,EAAE,CAAC;YAClC,MAAM,IAAI,4BAAmB,CAAC,yCAAyC,CAAC,CAAC;QAC3E,CAAC;QAED,MAAM,YAAY,GAAG,oBAAoB,GAAG,GAAG,CAAC;QAChD,MAAM,eAAe,GAAG,CAAC,GAAG,YAAY,CAAC;QAQzC,MAAM,wBAAwB,GAAG,UAAU,GAAG,CAAC,UAAU,GAAG,eAAe,CAAC,CAAC;QAE7E,MAAM,aAAa,GAAG,SAAS,GAAG,wBAAwB,CAAC;QAC3D,MAAM,aAAa,GAAG,aAAa,GAAG,eAAe,CAAC;QAGtD,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QAC3C,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QAE3C,MAAM,WAAW,GAAG,QAAQ,GAAG,UAAU,CAAC;QAC1C,MAAM,aAAa,GAAG,QAAQ,GAAG,UAAU,CAAC;QAC5C,MAAM,SAAS,GAAG,WAAW,GAAG,aAAa,CAAC;QAE9C,OAAO;YACL,WAAW,EAAE,SAAS;YACtB,WAAW,EAAE,SAAS;YACtB,QAAQ;YACR,QAAQ;YACR,kBAAkB,EAAE,SAAS,GAAG,SAAS;YACzC,WAAW;YACX,aAAa;SACd,CAAC;IACJ,CAAC;CACF;AA5DD,sCA4DC"}
+1
View File
@@ -0,0 +1 @@
import 'dotenv/config';
+86
View File
@@ -0,0 +1,86 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
Object.defineProperty(exports, "__esModule", { value: true });
const core_1 = require("@nestjs/core");
const common_1 = require("@nestjs/common");
const prisma_service_1 = require("./prisma.service");
require("dotenv/config");
let AppController = class AppController {
getHello() {
return 'Travel Planning API is running!';
}
};
__decorate([
(0, common_1.Get)(),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", String)
], AppController.prototype, "getHello", null);
AppController = __decorate([
(0, common_1.Controller)()
], AppController);
let TourController = class TourController {
constructor(prisma) {
this.prisma = prisma;
}
async getTourDetails(id) {
const tour = await this.prisma.tour.findUnique({
where: { id },
include: {
members: true,
photos: true,
legs: {
orderBy: { sequenceNumber: 'asc' },
include: {
places: { orderBy: { sequenceInLeg: 'asc' } },
expenses: true,
},
},
},
});
if (!tour)
throw new common_1.NotFoundException(`Không tìm thấy Tour với ID ${id}`);
return tour;
}
};
__decorate([
(0, common_1.Get)(':id'),
__param(0, (0, common_1.Param)('id', common_1.ParseIntPipe)),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Number]),
__metadata("design:returntype", Promise)
], TourController.prototype, "getTourDetails", null);
TourController = __decorate([
(0, common_1.Controller)('api/v1/tours'),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], TourController);
let AppModule = class AppModule {
};
AppModule = __decorate([
(0, common_1.Module)({
controllers: [AppController, TourController],
providers: [prisma_service_1.PrismaService],
exports: [prisma_service_1.PrismaService]
})
], AppModule);
async function bootstrap() {
const app = await core_1.NestFactory.create(AppModule);
app.enableCors();
app.setGlobalPrefix('api');
const port = process.env.PORT || 3001;
await app.listen(port);
console.log(`🚀 Server is running on: http://localhost:${port}`);
}
bootstrap();
//# sourceMappingURL=main.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"main.js","sourceRoot":"","sources":["../main.ts"],"names":[],"mappings":";;;;;;;;;;;;;;AAAA,uCAA2C;AAC3C,2CAAiG;AACjG,qDAAiD;AACjD,yBAAuB;AAGvB,IAAM,aAAa,GAAnB,MAAM,aAAa;IAEjB,QAAQ;QACN,OAAO,iCAAiC,CAAC;IAC3C,CAAC;CACF,CAAA;AAHC;IADC,IAAA,YAAG,GAAE;;;;6CAGL;AAJG,aAAa;IADlB,IAAA,mBAAU,GAAE;GACP,aAAa,CAKlB;AAED,IACM,cAAc,GADpB,MACM,cAAc;IAClB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAGvC,AAAN,KAAK,CAAC,cAAc,CAA4B,EAAU;QACxD,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE;YACb,OAAO,EAAE;gBACP,OAAO,EAAE,IAAI;gBACb,MAAM,EAAE,IAAI;gBACZ,IAAI,EAAE;oBACJ,OAAO,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE;oBAClC,OAAO,EAAE;wBACP,MAAM,EAAE,EAAE,OAAO,EAAE,EAAE,aAAa,EAAE,KAAK,EAAE,EAAE;wBAC7C,QAAQ,EAAE,IAAI;qBACf;iBACF;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI;YAAE,MAAM,IAAI,0BAAiB,CAAC,8BAA8B,EAAE,EAAE,CAAC,CAAC;QAC3E,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAnBO;IADL,IAAA,YAAG,EAAC,KAAK,CAAC;IACW,WAAA,IAAA,cAAK,EAAC,IAAI,EAAE,qBAAY,CAAC,CAAA;;;;oDAkB9C;AAtBG,cAAc;IADnB,IAAA,mBAAU,EAAC,cAAc,CAAC;qCAEG,8BAAa;GADrC,cAAc,CAuBnB;AAOD,IAAM,SAAS,GAAf,MAAM,SAAS;CAAG,CAAA;AAAZ,SAAS;IALd,IAAA,eAAM,EAAC;QACN,WAAW,EAAE,CAAC,aAAa,EAAE,cAAc,CAAC;QAC5C,SAAS,EAAE,CAAC,8BAAa,CAAC;QAC1B,OAAO,EAAE,CAAC,8BAAa,CAAC;KACzB,CAAC;GACI,SAAS,CAAG;AAElB,KAAK,UAAU,SAAS;IACtB,MAAM,GAAG,GAAG,MAAM,kBAAW,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IAChD,GAAG,CAAC,UAAU,EAAE,CAAC;IACjB,GAAG,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;IAE3B,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC;IACtC,MAAM,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACvB,OAAO,CAAC,GAAG,CAAC,6CAA6C,IAAI,EAAE,CAAC,CAAC;AACnE,CAAC;AACD,SAAS,EAAE,CAAC"}
+3
View File
@@ -0,0 +1,3 @@
import 'dotenv/config';
declare const _default: import("@prisma/config").PrismaConfigInternal;
export default _default;
+10
View File
@@ -0,0 +1,10 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const config_1 = require("@prisma/config");
require("dotenv/config");
exports.default = (0, config_1.defineConfig)({
datasource: {
url: process.env.DATABASE_URL,
},
});
//# sourceMappingURL=prisma.config.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"prisma.config.js","sourceRoot":"","sources":["../prisma.config.ts"],"names":[],"mappings":";;AAAA,2CAA8C;AAC9C,yBAAuB;AAEvB,kBAAe,IAAA,qBAAY,EAAC;IAC1B,UAAU,EAAE;QAEV,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY;KAC9B;CACF,CAAC,CAAC"}
+9
View File
@@ -0,0 +1,9 @@
import { OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import 'dotenv/config';
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private pool;
constructor();
onModuleInit(): Promise<void>;
onModuleDestroy(): Promise<void>;
}
+36
View File
@@ -0,0 +1,36 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PrismaService = void 0;
const common_1 = require("@nestjs/common");
const client_1 = require("@prisma/client");
const adapter_pg_1 = require("@prisma/adapter-pg");
const pg_1 = require("pg");
require("dotenv/config");
let PrismaService = class PrismaService extends client_1.PrismaClient {
constructor() {
const pool = new pg_1.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new adapter_pg_1.PrismaPg(pool);
super({ adapter });
this.pool = pool;
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() {
await this.$disconnect();
await this.pool.end();
}
};
exports.PrismaService = PrismaService;
exports.PrismaService = PrismaService = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [])
], PrismaService);
//# sourceMappingURL=prisma.service.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"prisma.service.js","sourceRoot":"","sources":["../prisma.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA2E;AAC3E,2CAA8C;AAC9C,mDAA8C;AAC9C,2BAA0B;AAC1B,yBAAuB;AAGhB,IAAM,aAAa,GAAnB,MAAM,aAAc,SAAQ,qBAAY;IAG7C;QACE,MAAM,IAAI,GAAG,IAAI,SAAI,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC;QACtE,MAAM,OAAO,GAAG,IAAI,qBAAQ,CAAC,IAAI,CAAC,CAAC;QACnC,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;QACnB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;IAED,KAAK,CAAC,YAAY,KAAK,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;IAC/C,KAAK,CAAC,eAAe;QACnB,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;QACzB,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;IACxB,CAAC;CACF,CAAA;AAfY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;;GACA,aAAa,CAezB"}
+7
View File
@@ -0,0 +1,7 @@
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { PrismaService } from './prisma.service';
export declare class TourRoleGuard implements CanActivate {
private prisma;
constructor(prisma: PrismaService);
canActivate(context: ExecutionContext): Promise<boolean>;
}
+54
View File
@@ -0,0 +1,54 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TourRoleGuard = void 0;
const common_1 = require("@nestjs/common");
const prisma_service_1 = require("./prisma.service");
let TourRoleGuard = class TourRoleGuard {
constructor(prisma) {
this.prisma = prisma;
}
async canActivate(context) {
const request = context.switchToHttp().getRequest();
const user = request.user;
const tourId = parseInt(request.params.id || request.params.tourId);
const path = request.url;
if (!user || isNaN(tourId)) {
throw new common_1.ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
}
const membership = await this.prisma.tourMember.findUnique({
where: {
tourId_userId: {
tourId: tourId,
userId: user.id,
},
},
});
if (!membership) {
throw new common_1.ForbiddenException("Bạn không phải là thành viên của tour này.");
}
request.tourMembership = membership;
const role = membership.role;
const isPlanPath = path.includes('/plans');
const isExpensePath = path.includes('/expenses');
if ((role === 'MEMBER_PHOTO_ONLY' || role === 'VIEWER_EXTERNAL') &&
(isPlanPath || isExpensePath)) {
throw new common_1.ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
}
return true;
}
};
exports.TourRoleGuard = TourRoleGuard;
exports.TourRoleGuard = TourRoleGuard = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], TourRoleGuard);
//# sourceMappingURL=rbac.middleware.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"rbac.middleware.js","sourceRoot":"","sources":["../rbac.middleware.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA+F;AAC/F,qDAAiD;AAG1C,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAG1B,MAAM,MAAM,GAAG,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACpE,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC;QAEzB,IAAI,CAAC,IAAI,IAAI,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;YAC3B,MAAM,IAAI,2BAAkB,CAAC,+CAA+C,CAAC,CAAC;QAChF,CAAC;QAOD,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,UAAU,CAAC;YACzD,KAAK,EAAE;gBACL,aAAa,EAAE;oBACb,MAAM,EAAE,MAAM;oBACd,MAAM,EAAE,IAAI,CAAC,EAAE;iBAChB;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,MAAM,IAAI,2BAAkB,CAAC,4CAA4C,CAAC,CAAC;QAC7E,CAAC;QAGD,OAAO,CAAC,cAAc,GAAG,UAAU,CAAC;QAEpC,MAAM,IAAI,GAAG,UAAU,CAAC,IAAI,CAAC;QAC7B,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAGjD,IACE,CAAC,IAAI,KAAK,mBAAmB,IAAI,IAAI,KAAK,iBAAiB,CAAC;YAC5D,CAAC,UAAU,IAAI,aAAa,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,2BAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAlDY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,aAAa,CAkDzB"}
+1
View File
@@ -0,0 +1 @@
import 'dotenv/config';
+97
View File
@@ -0,0 +1,97 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const client_1 = require("@prisma/client");
const adapter_pg_1 = require("@prisma/adapter-pg");
const pg_1 = require("pg");
require("dotenv/config");
const pool = new pg_1.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new adapter_pg_1.PrismaPg(pool);
const prisma = new client_1.PrismaClient({ adapter });
async function main() {
console.log('--- Đang xóa dữ liệu cũ... ---');
await prisma.expense.deleteMany();
await prisma.photo.deleteMany();
await prisma.tour.deleteMany();
await prisma.user.deleteMany();
console.log('--- Đang tạo người dùng mẫu... ---');
const owner = await prisma.user.create({
data: {
email: 'owner@travel.com',
name: 'Lộc Phạm (Chủ Tour)',
passwordHash: 'hashed_password_123',
},
});
const photoMember = await prisma.user.create({
data: {
email: 'photomember@travel.com',
name: 'Nguyễn Văn Ảnh (Chỉ xem ảnh)',
passwordHash: 'hashed_password_456',
},
});
console.log('--- Đang tạo Tour và phân quyền... ---');
const tour = await prisma.tour.create({
data: {
title: 'Hành trình khám phá TP.HCM',
startDate: new Date('2023-11-20'),
endDate: new Date('2023-11-21'),
creatorId: owner.id,
members: {
create: [
{ userId: owner.id, role: 'OWNER' },
{ userId: photoMember.id, role: 'MEMBER_PHOTO_ONLY' },
],
},
},
});
console.log('--- Đang tạo chặng và địa điểm... ---');
const leg1 = await prisma.leg.create({
data: {
tourId: tour.id,
sequenceNumber: 1,
notes: 'Khám phá lịch sử trung tâm',
places: {
create: [
{
name: 'Dinh Độc Lập',
address: '135 Nam Kỳ Khởi Nghĩa, Quận 1',
latitude: 10.777,
longitude: 106.695,
sequenceInLeg: 1,
arrivalTime: new Date('2023-11-20T08:00:00Z'),
},
{
name: 'Bưu điện Thành phố',
address: '02 Công xã Paris, Quận 1',
latitude: 10.779,
longitude: 106.699,
sequenceInLeg: 2,
arrivalTime: new Date('2023-11-20T10:00:00Z'),
},
],
},
},
});
console.log('--- Đang tạo chi phí mẫu... ---');
await prisma.expense.create({
data: {
legId: leg1.id,
category: 'DINING',
amount: 500000,
currency: 'VND',
description: 'Ăn trưa đặc sản Quận 1',
},
});
console.log('--- Seed dữ liệu hoàn tất! ---');
console.log(`Email đăng nhập Owner: ${owner.email}`);
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
console.log(`Tour ID để test: ${tour.id}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await pool.end();
});
//# sourceMappingURL=seed.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"seed.js","sourceRoot":"","sources":["../seed.ts"],"names":[],"mappings":"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"}
File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
interface TourState {
currentTour: any;
legs: any[];
userRole: string | null;
setTour: (tour: any) => void;
updateLegs: (legs: any[]) => void;
optimizeRouting: () => Promise<void>;
fetchTour: (id: number) => Promise<void>;
}
export declare const useTourStore: import("zustand").UseBoundStore<import("zustand").StoreApi<TourState>>;
export {};
+21
View File
@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.useTourStore = void 0;
const zustand_1 = require("zustand");
exports.useTourStore = (0, zustand_1.create)((set, get) => ({
currentTour: null,
legs: [],
userRole: null,
setTour: (tour) => set({ currentTour: tour }),
updateLegs: (legs) => set({ legs }),
fetchTour: async (id) => {
const currentUserId = 1;
const response = await fetch(`http://localhost:3001/api/v1/tours/${id}`);
const data = await response.json();
const role = data.members?.find((m) => m.userId === currentUserId)?.role || 'VIEWER_EXTERNAL';
set({ currentTour: data, legs: data.legs || [], userRole: role });
},
optimizeRouting: async () => {
},
}));
//# sourceMappingURL=useTourStore.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"useTourStore.js","sourceRoot":"","sources":["../useTourStore.ts"],"names":[],"mappings":";;;AAAA,qCAAiC;AAYpB,QAAA,YAAY,GAAG,IAAA,gBAAM,EAAY,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;IAC3D,WAAW,EAAE,IAAI;IACjB,IAAI,EAAE,EAAE;IACR,QAAQ,EAAE,IAAI;IACd,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;IAC7C,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,CAAC;IACnC,SAAS,EAAE,KAAK,EAAE,EAAU,EAAE,EAAE;QAE9B,MAAM,aAAa,GAAG,CAAC,CAAC;QACxB,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,sCAAsC,EAAE,EAAE,CAAC,CAAC;QACzE,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;QAEnC,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAM,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,aAAa,CAAC,EAAE,IAAI,IAAI,iBAAiB,CAAC;QACnG,GAAG,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;IACpE,CAAC;IACD,eAAe,EAAE,KAAK,IAAI,EAAE;IAE5B,CAAC;CACF,CAAC,CAAC,CAAC"}
+67
View File
@@ -0,0 +1,67 @@
import { BadRequestException } from '@nestjs/common';
/**
* Engine tính toán chi phí dựa trên công thức trong ARCHITECTURE.md
* T = N_adult * X + N_child * (X * (1 - P_child_discount))
*/
export class ExpenseEngine {
/**
* Tính toán phân chia chi phí giữa người lớn và trẻ em
* @param totalCost Tổng chi phí (T)
* @param adultCount Số lượng người lớn (N_adult)
* @param childCount Số lượng trẻ em (N_child)
* @param childDiscountPercent Phần trăm giảm giá trẻ em (P_child_discount, ví dụ: 30)
*/
calculateSplit(
totalCost: number,
adultCount: number,
childCount: number,
childDiscountPercent: number
) {
// 1. Validation: Kiểm tra dữ liệu đầu vào âm
if (totalCost < 0 || adultCount < 0 || childCount < 0 || childDiscountPercent < 0) {
throw new BadRequestException('Dữ liệu đầu vào không được là số âm.');
}
if (childDiscountPercent > 100) {
throw new BadRequestException('Phần trăm giảm giá không thể vượt quá 100%.');
}
// 2. Handle division by zero: Không có người tham gia
if (adultCount + childCount === 0) {
throw new BadRequestException('Số lượng người tham gia phải lớn hơn 0.');
}
const discountRate = childDiscountPercent / 100;
const childRateFactor = 1 - discountRate;
/**
* Giải phương trình:
* T = X * N_adult + X * (1 - P) * N_child
* T = X * (N_adult + N_child * (1 - P))
* => X = T / (N_adult + N_child * (1 - P))
*/
const weightedParticipantCount = adultCount + (childCount * childRateFactor);
const adultPriceRaw = totalCost / weightedParticipantCount;
const childPriceRaw = adultPriceRaw * childRateFactor;
// Làm tròn để thực tế thanh toán (ví dụ: làm tròn đến hàng đơn vị)
const perAdult = Math.round(adultPriceRaw);
const perChild = Math.round(childPriceRaw);
const adultsTotal = perAdult * adultCount;
const childrenTotal = perChild * childCount;
const actualSum = adultsTotal + childrenTotal;
return {
targetTotal: totalCost,
actualTotal: actualSum,
perAdult,
perChild,
roundingDifference: totalCost - actualSum, // Chênh lệch do làm tròn (nếu có)
adultsTotal,
childrenTotal,
};
}
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Travel Planner</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+100
View File
@@ -0,0 +1,100 @@
import { NestFactory } from '@nestjs/core';
import { Module, Controller, Get, Post, Body, Param, ParseIntPipe, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import 'dotenv/config';
import * as bcrypt from 'bcrypt';
@Controller()
class AppController {
@Get()
getHello(): string {
return 'Travel Planning API is running!';
}
}
@Controller('v1/auth')
class AuthController {
constructor(private prisma: PrismaService) {}
@Post('signup')
async signup(@Body() body: any) {
const { email, password, name } = body;
// 1. Kiểm tra email tồn tại
const existingUser = await this.prisma.user.findUnique({ where: { email } });
if (existingUser) throw new BadRequestException('Email đã được sử dụng');
// 2. Logic "First User is Admin"
const userCount = await this.prisma.user.count();
const shouldBeAdmin = userCount === 0;
// 3. Hash mật khẩu
const passwordHash = await bcrypt.hash(password, 10);
return this.prisma.user.create({
data: { email, passwordHash, name, isAdmin: shouldBeAdmin },
select: { id: true, email: true, name: true, isAdmin: true }
});
}
}
@Controller('v1/tours')
class TourController {
constructor(private prisma: PrismaService) {}
@Get('explore')
async getPublicTours() {
// Lấy các tour có ít nhất 1 ảnh và thông tin vị trí từ chặng đầu tiên
return this.prisma.tour.findMany({
take: 20,
include: {
photos: { take: 1 },
legs: {
take: 1,
include: {
places: { take: 1 }
}
}
}
});
}
@Get(':id')
async getTourDetails(@Param('id', ParseIntPipe) id: number) {
const tour = await this.prisma.tour.findUnique({
where: { id },
include: {
members: true,
photos: true,
legs: {
orderBy: { sequenceNumber: 'asc' },
include: {
places: { orderBy: { sequenceInLeg: 'asc' } },
expenses: true,
},
},
},
});
if (!tour) throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`);
return tour;
}
}
@Module({
controllers: [AppController, AuthController, TourController],
providers: [PrismaService],
exports: [PrismaService]
})
class AppModule {}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors(); // Cho phép Frontend gọi API
app.setGlobalPrefix('api'); // Tất cả API sẽ bắt đầu bằng /api/...
const port = process.env.PORT || 3001;
await app.listen(port);
console.log(`🚀 Server is running on: http://localhost:${port}`);
}
bootstrap();
@@ -0,0 +1,148 @@
-- CreateEnum
CREATE TYPE "MemberRole" AS ENUM ('OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY', 'MEMBER_PHOTO_ONLY', 'VIEWER_EXTERNAL');
-- CreateEnum
CREATE TYPE "ExpenseCategory" AS ENUM ('LODGING', 'DINING', 'TRANSPORT', 'OTHER');
-- CreateEnum
CREATE TYPE "TriggerType" AS ENUM ('AUTO_BY_TIME', 'MANUAL_BY_USER');
-- CreateEnum
CREATE TYPE "PrivacyLevel" AS ENUM ('PUBLIC_IN_TOUR', 'PRIVATE_OWNER');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT,
"avatar" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tour" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"startDate" TIMESTAMP(3),
"endDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"creatorId" INTEGER NOT NULL,
CONSTRAINT "Tour_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TourMember" (
"tourId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"role" "MemberRole" NOT NULL DEFAULT 'MEMBER_PLAN_ONLY',
CONSTRAINT "TourMember_pkey" PRIMARY KEY ("tourId","userId")
);
-- CreateTable
CREATE TABLE "Leg" (
"id" SERIAL NOT NULL,
"tourId" INTEGER NOT NULL,
"sequenceNumber" INTEGER NOT NULL,
"notes" TEXT,
CONSTRAINT "Leg_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Place" (
"id" SERIAL NOT NULL,
"legId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT,
"latitude" DOUBLE PRECISION NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"sequenceInLeg" INTEGER NOT NULL,
"arrivalTime" TIMESTAMP(3),
"departureTime" TIMESTAMP(3),
CONSTRAINT "Place_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Expense" (
"id" SERIAL NOT NULL,
"legId" INTEGER NOT NULL,
"placeId" INTEGER,
"category" "ExpenseCategory" NOT NULL,
"amount" DECIMAL(15,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'VND',
"description" TEXT,
CONSTRAINT "Expense_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Task" (
"id" SERIAL NOT NULL,
"tourId" INTEGER NOT NULL,
"legId" INTEGER,
"title" TEXT NOT NULL,
"plannedTimestamp" TIMESTAMP(3) NOT NULL,
"isCompleted" BOOLEAN NOT NULL DEFAULT false,
"completedAt" TIMESTAMP(3),
"triggerType" "TriggerType" NOT NULL DEFAULT 'MANUAL_BY_USER',
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Photo" (
"id" SERIAL NOT NULL,
"tourId" INTEGER NOT NULL,
"placeId" INTEGER,
"uploaderId" INTEGER NOT NULL,
"imageUrl" TEXT NOT NULL,
"capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"privacyLevel" "PrivacyLevel" NOT NULL DEFAULT 'PUBLIC_IN_TOUR',
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- AddForeignKey
ALTER TABLE "Tour" ADD CONSTRAINT "Tour_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourMember" ADD CONSTRAINT "TourMember_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourMember" ADD CONSTRAINT "TourMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Leg" ADD CONSTRAINT "Leg_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Place" ADD CONSTRAINT "Place_legId_fkey" FOREIGN KEY ("legId") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_legId_fkey" FOREIGN KEY ("legId") REFERENCES "Leg"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_placeId_fkey" FOREIGN KEY ("placeId") REFERENCES "Place"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_legId_fkey" FOREIGN KEY ("legId") REFERENCES "Leg"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_placeId_fkey" FOREIGN KEY ("placeId") REFERENCES "Place"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": ".",
"compilerOptions": {
"deleteOutDir": true
}
}
+7193
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "travel-planning-backend",
"version": "1.0.0",
"description": "Travel Planning Backend with NestJS and Prisma 7",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"frontend": "vite",
"db:migrate": "npx prisma migrate dev",
"db:generate": "npx prisma generate",
"db:seed": "npx tsx seed.ts"
},
"dependencies": {
"@nestjs/common": "^11.1.26",
"@nestjs/core": "^11.1.26",
"@nestjs/platform-express": "^11.1.26",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"date-fns": "^2.30.0",
"dotenv": "^16.3.0",
"lucide-react": "^0.284.0",
"pg": "^8.11.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"zustand": "^5.0.1",
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@types/node": "^20.0.0",
"@types/pg": "^8.10.0",
"@types/react": "^18.3.12",
"@types/leaflet": "^1.9.12",
"@vitejs/plugin-react": "^6.0.2",
"@types/bcrypt": "^5.0.2",
"prisma": "^7.8.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^8.0.16"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from '@prisma/config';
import 'dotenv/config';
export default defineConfig({
datasource: {
// Prisma CLI sẽ sử dụng url này để thực hiện migrate
url: process.env.DATABASE_URL,
},
});
+23
View File
@@ -0,0 +1,23 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import 'dotenv/config';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private pool: Pool;
constructor() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
super({ adapter });
this.pool = pool;
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() {
await this.$disconnect();
await this.pool.end();
}
}
+55
View File
@@ -0,0 +1,55 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Injectable()
export class TourRoleGuard implements CanActivate {
constructor(private prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user; // Giả sử đã qua AuthGuard (Passport/JWT)
// Lấy tourId từ URL params (:id hoặc :tourId)
const tourId = parseInt(request.params.id || request.params.tourId);
const path = request.url;
if (!user || isNaN(tourId)) {
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
}
/**
* TỐI ƯU: Chỉ truy vấn Database 1 lần duy nhất để lấy thông tin thành viên.
* Chúng ta lưu kết quả vào request object để các interceptor hoặc controller
* sau này có thể dùng lại mà không cần query lại.
*/
const membership = await this.prisma.tourMember.findUnique({
where: {
tourId_userId: {
tourId: tourId,
userId: user.id,
},
},
});
if (!membership) {
throw new ForbiddenException("Bạn không phải là thành viên của tour này.");
}
// Gắn thông tin vào request để sử dụng ở tầng Controller
request.tourMembership = membership;
const role = membership.role;
const isPlanPath = path.includes('/plans');
const isExpensePath = path.includes('/expenses');
// 1. Chặn MEMBER_PHOTO_ONLY và VIEWER_EXTERNAL truy cập Plans & Expenses
if (
(role === 'MEMBER_PHOTO_ONLY' || role === 'VIEWER_EXTERNAL') &&
(isPlanPath || isExpensePath)
) {
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
}
return true;
}
}
+153
View File
@@ -0,0 +1,153 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// --- Enums ---
enum MemberRole {
OWNER
EDITOR
MEMBER_PLAN_ONLY
MEMBER_PHOTO_ONLY
VIEWER_EXTERNAL
}
enum ExpenseCategory {
LODGING
DINING
TRANSPORT
OTHER
}
enum TriggerType {
AUTO_BY_TIME
MANUAL_BY_USER
}
enum PrivacyLevel {
PUBLIC_IN_TOUR
PRIVATE_OWNER
}
// --- Models ---
model User {
id Int @id @default(autoincrement())
email String @unique
passwordHash String
name String?
avatar String?
createdAt DateTime @default(now())
isAdmin Boolean @default(false)
createdTours Tour[] @relation("TourCreator")
tourMemberships TourMember[]
uploadedPhotos Photo[]
}
model Tour {
id Int @id @default(autoincrement())
title String
startDate DateTime?
endDate DateTime?
createdAt DateTime @default(now())
creatorId Int
creator User @relation("TourCreator", fields: [creatorId], references: [id])
members TourMember[]
legs Leg[]
tasks Task[]
photos Photo[]
}
model TourMember {
tourId Int
userId Int
role MemberRole @default(MEMBER_PLAN_ONLY)
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([tourId, userId])
}
model Leg {
id Int @id @default(autoincrement())
tourId Int
sequenceNumber Int
notes String? @db.Text
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
places Place[]
expenses Expense[]
tasks Task[]
}
model Place {
id Int @id @default(autoincrement())
legId Int
name String
address String?
// Sử dụng Float cho Lat/Lng để dễ thao tác ở Frontend.
// Nếu bạn muốn dùng PostGIS chuyên sâu, có thể dùng Unsupported("geography(Point, 4326)")
latitude Float
longitude Float
sequenceInLeg Int
arrivalTime DateTime?
departureTime DateTime?
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
expenses Expense[]
photos Photo[]
}
model Expense {
id Int @id @default(autoincrement())
legId Int
placeId Int?
category ExpenseCategory
amount Decimal @db.Decimal(15, 2)
currency String @default("VND")
description String? @db.Text
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
place Place? @relation(fields: [placeId], references: [id], onDelete: Cascade)
}
model Task {
id Int @id @default(autoincrement())
tourId Int
legId Int?
title String
plannedTimestamp DateTime
isCompleted Boolean @default(false)
completedAt DateTime?
triggerType TriggerType @default(MANUAL_BY_USER)
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
leg Leg? @relation(fields: [legId], references: [id])
}
model Photo {
id Int @id @default(autoincrement())
tourId Int
placeId Int?
uploaderId Int
imageUrl String
capturedAt DateTime @default(now())
privacyLevel PrivacyLevel @default(PUBLIC_IN_TOUR)
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
place Place? @relation(fields: [placeId], references: [id], onDelete: SetNull)
uploader User @relation(fields: [uploaderId], references: [id])
}
+77
View File
@@ -0,0 +1,77 @@
-- Kích hoạt extension PostGIS để xử lý tọa độ địa lý
CREATE EXTENSION IF NOT EXISTS postgis;
-- 3.1. Users
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name VARCHAR(100),
avatar TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 3.2. Tours & Members
CREATE TABLE tours (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
start_date DATE,
end_date DATE,
creator_id INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TYPE member_role AS ENUM ('OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY', 'MEMBER_PHOTO_ONLY', 'VIEWER_EXTERNAL');
CREATE TABLE tour_members (
tour_id INTEGER REFERENCES tours(id),
user_id INTEGER REFERENCES users(id),
role member_role DEFAULT 'MEMBER_PLAN_ONLY',
PRIMARY KEY (tour_id, user_id)
);
-- 3.3. Itinerary & Map
CREATE TABLE legs (
id SERIAL PRIMARY KEY,
tour_id INTEGER REFERENCES tours(id),
sequence_number INTEGER NOT NULL,
notes TEXT
);
CREATE TABLE places (
id SERIAL PRIMARY KEY,
leg_id INTEGER REFERENCES legs(id),
name VARCHAR(255),
address TEXT,
geom GEOMETRY(Point, 4326), -- PostGIS coordinates
sequence_in_leg INTEGER,
arrival_time TIMESTAMP,
departure_time TIMESTAMP
);
-- 3.4. Expenses
CREATE TYPE expense_category AS ENUM ('LODGING', 'DINING', 'TRANSPORT', 'OTHER');
CREATE TABLE expenses (
id SERIAL PRIMARY KEY,
leg_id INTEGER REFERENCES legs(id),
place_id INTEGER REFERENCES places(id),
category expense_category,
amount DECIMAL(15, 2) NOT NULL,
currency VARCHAR(10) DEFAULT 'VND',
description TEXT
);
-- 3.5. Tasks
CREATE TYPE trigger_type AS ENUM ('AUTO_BY_TIME', 'MANUAL_BY_USER');
CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
tour_id INTEGER REFERENCES tours(id),
leg_id INTEGER REFERENCES legs(id),
title VARCHAR(255),
planned_timestamp TIMESTAMP,
is_completed BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMP,
trigger_type trigger_type DEFAULT 'MANUAL_BY_USER'
);
+104
View File
@@ -0,0 +1,104 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import 'dotenv/config';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
console.log('--- Đang xóa dữ liệu cũ... ---');
// Xóa các bảng phụ thuộc (nhiều bản ghi) trước
await prisma.expense.deleteMany();
await prisma.photo.deleteMany();
await prisma.tour.deleteMany();
await prisma.user.deleteMany();
console.log('--- Đang tạo người dùng mẫu... ---');
const owner = await prisma.user.create({
data: {
email: 'owner@travel.com',
name: 'Lộc Phạm (Chủ Tour)',
passwordHash: 'hashed_password_123',
},
});
const photoMember = await prisma.user.create({
data: {
email: 'photomember@travel.com',
name: 'Nguyễn Văn Ảnh (Chỉ xem ảnh)',
passwordHash: 'hashed_password_456',
},
});
console.log('--- Đang tạo Tour và phân quyền... ---');
const tour = await prisma.tour.create({
data: {
title: 'Hành trình khám phá TP.HCM',
startDate: new Date('2023-11-20'),
endDate: new Date('2023-11-21'),
creatorId: owner.id,
members: {
create: [
{ userId: owner.id, role: 'OWNER' },
{ userId: photoMember.id, role: 'MEMBER_PHOTO_ONLY' },
],
},
},
});
console.log('--- Đang tạo chặng và địa điểm... ---');
const leg1 = await prisma.leg.create({
data: {
tourId: tour.id,
sequenceNumber: 1,
notes: 'Khám phá lịch sử trung tâm',
places: {
create: [
{
name: 'Dinh Độc Lập',
address: '135 Nam Kỳ Khởi Nghĩa, Quận 1',
latitude: 10.777,
longitude: 106.695,
sequenceInLeg: 1,
arrivalTime: new Date('2023-11-20T08:00:00Z'),
},
{
name: 'Bưu điện Thành phố',
address: '02 Công xã Paris, Quận 1',
latitude: 10.779,
longitude: 106.699,
sequenceInLeg: 2,
arrivalTime: new Date('2023-11-20T10:00:00Z'),
},
],
},
},
});
console.log('--- Đang tạo chi phí mẫu... ---');
await prisma.expense.create({
data: {
legId: leg1.id,
category: 'DINING',
amount: 500000,
currency: 'VND',
description: 'Ăn trưa đặc sản Quận 1',
},
});
console.log('--- Seed dữ liệu hoàn tất! ---');
console.log(`Email đăng nhập Owner: ${owner.email}`);
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
console.log(`Tour ID để test: ${tour.id}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await pool.end();
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "commonjs",
"jsx": "react-jsx",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand';
interface TourState {
currentTour: any;
legs: any[];
publicTours: any[];
userRole: string | null;
setTour: (tour: any) => void;
updateLegs: (legs: any[]) => void;
optimizeRouting: () => Promise<void>;
fetchTour: (id: number) => Promise<void>;
fetchPublicTours: () => Promise<void>;
}
export const useTourStore = create<TourState>((set, get) => ({
currentTour: null,
legs: [],
publicTours: [],
userRole: null,
setTour: (tour) => set({ currentTour: tour }),
updateLegs: (legs) => set({ legs }),
fetchTour: async (id: number) => {
// Giả sử user hiện tại có ID là 1 (Lộc Phạm - OWNER) để test
const currentUserId = 1;
const response = await fetch(`http://localhost:3001/api/v1/tours/${id}`);
const data = await response.json();
const role = data.members?.find((m: any) => m.userId === currentUserId)?.role || 'VIEWER_EXTERNAL';
set({ currentTour: data, legs: data.legs || [], userRole: role });
},
fetchPublicTours: async () => {
const response = await fetch(`http://localhost:3001/api/v1/tours/explore`);
const data = await response.json();
set({ publicTours: data });
},
optimizeRouting: async () => {
// Logic gọi API /api/v1/routing/optimize và cập nhật lại state
},
}));
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3002,
strictPort: true,
},
});