Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91ef33a456 | |||
| 6f2dd0fc01 | |||
| 623f0164c8 | |||
| 88b47099b1 | |||
| 659d2a0840 | |||
| 68950dba10 | |||
| 3e215ae8e8 | |||
| e7b8c672e0 | |||
| 2462850e2e | |||
| abb61f65cd | |||
| da32d59161 | |||
| 40b1c4f2ab | |||
| 55de887f87 | |||
| ffe3cb6ecd | |||
| 69c67a1636 | |||
| f181009fa5 | |||
| b6551da147 | |||
| 2afb2971da | |||
| 14ffbb8657 | |||
| fbe5ef873b | |||
| d6ee1dde74 | |||
| 288b40ad12 | |||
| 3a3296c340 | |||
| e664a3797e | |||
| 36418673db | |||
| 98e4a4a340 | |||
| bfbbb66747 | |||
| 1933b58f84 | |||
| 05dc80bb6d | |||
| 4d63bff214 | |||
| c5474f1ff6 | |||
| 7ad785fed9 | |||
| e66f242c2c |
@@ -0,0 +1,133 @@
|
||||
# Travel Planning - Multi-User Travel Itinerary Management System
|
||||
|
||||
A comprehensive travel planning application that enables collaborative itinerary planning, expense tracking, and photo sharing for travel groups.
|
||||
|
||||
## Features
|
||||
|
||||
- **Tour Management**: Create and manage travel tours with multiple legs (stages) and locations
|
||||
- **Multi-user Collaboration**: Invite friends to join tours with role-based access control
|
||||
- **Interactive Maps**: Visual tour planning with Leaflet.js integration
|
||||
- **Expense Tracking**: Automatic cost splitting with configurable adult/child discounts
|
||||
- **Photo Sharing**: Secure photo album with privacy controls (PUBLIC, TOUR_ONLY, PRIVATE)
|
||||
- **Real-time Navigation**: Live GPS tracking and route optimization using OSRM API
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| **Frontend** | React + Vite | Single Page Application with fast HMR |
|
||||
| | TailwindCSS | Utility-first CSS framework |
|
||||
| | Zustand | Lightweight state management |
|
||||
| | Leaflet.js | Interactive map rendering |
|
||||
| **Backend** | NestJS | Scalable Node.js framework |
|
||||
| | JWT | Authentication & authorization |
|
||||
| | Prisma ORM | Type-safe database access |
|
||||
| | PostgreSQL + PostGIS | Spatial database for geographic data |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
travelplanning/
|
||||
├── backend/ # Backend API server
|
||||
│ ├── src/
|
||||
│ │ ├── auth/ # Authentication modules
|
||||
│ │ ├── main.ts # NestJS entry point
|
||||
│ │ └── v1/ # API v1 endpoints
|
||||
│ └── prisma/
|
||||
│ └── schema.prisma # Database schema
|
||||
├── frontend/ # React frontend
|
||||
│ └── src/
|
||||
│ ├── pages/ # Main pages
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ └── store/ # Zustand stores
|
||||
├── docs/ # Documentation
|
||||
│ ├── ARCHITECTURE.md # System architecture
|
||||
│ └── UITourDesign.md # UI design specifications
|
||||
└── .env # Environment variables
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
The application uses PostgreSQL with Prisma ORM. Key models include:
|
||||
|
||||
- **User**: Registered users with admin capability
|
||||
- **Tour**: Travel itineraries with date ranges and participant management
|
||||
- **Leg**: Stages within a tour (ordered sequence)
|
||||
- **Location**: Geographic points with timing and status tracking
|
||||
- **Expense**: Cost tracking linked to legs/locations
|
||||
- **Photo**: Media storage with privacy controls
|
||||
- **TourParticipant**: Many-to-many relationship with role-based permissions
|
||||
|
||||
### User Roles
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| OWNER | Full access to all features |
|
||||
| MANAGER | Can edit tour content and manage members |
|
||||
| MEMBER | View tour and participate, access financial data |
|
||||
| MEMBER_NO_FINANCE | View tour only, no financial access |
|
||||
| VIEWER_ONLY | Read-only access to itinerary and photos |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Tours
|
||||
- `GET /api/v1/tours` - Get all public tours
|
||||
- `POST /api/v1/tours` - Create new tour
|
||||
- `GET /api/v1/tours/:id` - Get tour details
|
||||
- `PUT /api/v1/tours/:id` - Update tour
|
||||
|
||||
### Authentication
|
||||
- `POST /api/v1/auth/login` - User login
|
||||
- `POST /api/v1/auth/register` - User registration
|
||||
- `POST /api/v1/auth/promote-admin` - Admin role promotion (with secret key)
|
||||
|
||||
### Photos
|
||||
- `GET /api/v1/public-photos` - Get public photos
|
||||
- `POST /api/v1/tours/:id/photos` - Upload tour photos
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- PostgreSQL with PostGIS extension
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
cd frontend && npm install
|
||||
cd ../backend && npm install
|
||||
|
||||
# Set up database
|
||||
npx prisma migrate dev
|
||||
npx prisma generate
|
||||
|
||||
# Start development servers
|
||||
npm run dev # Frontend (Vite)
|
||||
npm run start:backend # Backend (NestJS)
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create `.env` in the root directory:
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/traveldb"
|
||||
JWT_SECRET="your-secret-key"
|
||||
```
|
||||
|
||||
## Mobile Optimization
|
||||
|
||||
The application is built mobile-first with support for:
|
||||
- Safe area insets for notch displays (iOS/Android)
|
||||
- Touch gestures for map interactions
|
||||
- Responsive layouts for all screen sizes
|
||||
- Device orientation and compass integration
|
||||
|
||||
## Documentation
|
||||
|
||||
See the `docs/` directory for detailed documentation:
|
||||
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - System architecture and data models
|
||||
- [UITourDesign.md](docs/UITourDesign.md) - UI design specifications
|
||||
Vendored
+5
@@ -15,6 +15,11 @@ export declare class TourRoleGuard implements CanActivate {
|
||||
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
|
||||
canActivate(context: ExecutionContext): Promise<boolean>;
|
||||
}
|
||||
export declare class EmailService {
|
||||
private transporter;
|
||||
constructor();
|
||||
sendOTP(email: string, otp: string): Promise<any>;
|
||||
}
|
||||
export declare class CommentGateway implements OnGatewayConnection {
|
||||
server: Server;
|
||||
handleConnection(client: Socket): void;
|
||||
|
||||
Vendored
+353
-38
@@ -48,13 +48,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CommentGateway = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = void 0;
|
||||
const dotenv = __importStar(require("dotenv"));
|
||||
exports.CommentGateway = exports.EmailService = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = void 0;
|
||||
const path = __importStar(require("path"));
|
||||
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
require("reflect-metadata");
|
||||
const fs = __importStar(require("fs"));
|
||||
const config_1 = require("@nestjs/config");
|
||||
require("reflect-metadata");
|
||||
const zlib = __importStar(require("zlib"));
|
||||
const util_1 = require("util");
|
||||
const sharp_1 = __importDefault(require("sharp"));
|
||||
@@ -67,6 +65,7 @@ const prisma_service_1 = require("../prisma/prisma.service");
|
||||
const client_1 = require("@prisma/client");
|
||||
const bcrypt = __importStar(require("bcrypt"));
|
||||
const admin_guard_1 = require("./auth/admin.guard");
|
||||
const nodemailer = __importStar(require("nodemailer"));
|
||||
const jwt_1 = require("@nestjs/jwt");
|
||||
const jwt_auth_guard_1 = require("./auth/jwt-auth.guard");
|
||||
const jwt_strategy_1 = require("./auth/jwt.strategy");
|
||||
@@ -84,12 +83,6 @@ const CACHE_TTL = {
|
||||
USER_ROLE: 300000,
|
||||
};
|
||||
async function bootstrap() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('❌ DATABASE_URL không tồn tại trong biến môi trường! Kiểm tra file .env tại: ' + envPath);
|
||||
}
|
||||
console.log('====================================');
|
||||
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||
console.log('====================================');
|
||||
const app = await core_1.NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableCors();
|
||||
@@ -189,6 +182,9 @@ let TourRoleGuard = class TourRoleGuard {
|
||||
if (!rolesToCheck.some(r => role === r)) {
|
||||
throw new common_1.ForbiddenException('Bạn không có quyền thực hiện hành động này trong tour này.');
|
||||
}
|
||||
if (tourId) {
|
||||
request.tourId = tourId;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
@@ -199,6 +195,68 @@ exports.TourRoleGuard = TourRoleGuard = __decorate([
|
||||
__metadata("design:paramtypes", [core_2.Reflector,
|
||||
prisma_service_1.PrismaService, Object])
|
||||
], TourRoleGuard);
|
||||
let EmailService = class EmailService {
|
||||
constructor() {
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
if (!user || !pass) {
|
||||
console.warn('[EmailService] ⚠️ SMTP_USER hoặc SMTP_PASS chưa được cấu hình trong file .env. Tính năng gửi mã OTP sẽ không khả dụng.');
|
||||
}
|
||||
else {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: user,
|
||||
pass: pass,
|
||||
},
|
||||
});
|
||||
this.transporter.verify((error) => {
|
||||
if (error) {
|
||||
console.error('[EmailService] ❌ Lỗi kết nối SMTP:', error.message);
|
||||
}
|
||||
else {
|
||||
console.log('[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
async sendOTP(email, otp) {
|
||||
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
|
||||
throw new Error('Thiếu cấu hình SMTP_USER hoặc SMTP_PASS trong file .env');
|
||||
}
|
||||
const mailOptions = {
|
||||
from: `"Travel Planning Support" <${process.env.SMTP_USER}>`,
|
||||
to: email,
|
||||
subject: 'Mã OTP Xác Thực Hệ Thống',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 10px;">
|
||||
<h2 style="color: #2563eb; text-align: center;">Mã Xác Thực OTP</h2>
|
||||
<p>Xin chào,</p>
|
||||
<p>Quản trị viên hệ thống đã yêu cầu cấp và gửi mã OTP xác thực tới tài khoản của bạn. Vui lòng sử dụng mã bảo mật dưới đây:</p>
|
||||
<div style="background-color: #f3f4f6; padding: 15px; text-align: center; font-size: 24px; font-weight: bold; letter-spacing: 5px; color: #1e3a8a; margin: 20px 0; border-radius: 5px;">
|
||||
${otp}
|
||||
</div>
|
||||
<p>Mã OTP này có hiệu lực trong vòng 5 phút. Vui lòng tuyệt đối không chia sẻ mã này cho bất kỳ ai khác.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;" />
|
||||
<p style="font-size: 12px; color: #6b7280; text-align: center;">Đây là email tự động từ ứng dụng hệ thống Travel Planning.</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
try {
|
||||
return await this.transporter.sendMail(mailOptions);
|
||||
}
|
||||
catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
exports.EmailService = EmailService;
|
||||
exports.EmailService = EmailService = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [])
|
||||
], EmailService);
|
||||
let AppController = class AppController {
|
||||
getHello() {
|
||||
return 'Travel Planning API is running!';
|
||||
@@ -214,9 +272,12 @@ AppController = __decorate([
|
||||
(0, common_1.Controller)()
|
||||
], AppController);
|
||||
let AuthController = class AuthController {
|
||||
constructor(prisma, jwtService) {
|
||||
constructor(prisma, jwtService, configService, emailService, cacheManager) {
|
||||
this.prisma = prisma;
|
||||
this.jwtService = jwtService;
|
||||
this.configService = configService;
|
||||
this.emailService = emailService;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
async getStatus() {
|
||||
const userCount = await this.prisma.user.count();
|
||||
@@ -243,18 +304,46 @@ let AuthController = class AuthController {
|
||||
},
|
||||
};
|
||||
}
|
||||
async signup(body) {
|
||||
async signupRequest(body) {
|
||||
const { email, password, name, phone, address } = body;
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existingUser)
|
||||
throw new common_1.BadRequestException('Email đã được sử dụng');
|
||||
const otp = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
await this.cacheManager.set(`signup_otp:${email}`, otp, 300000);
|
||||
await this.cacheManager.set(`signup_data:${email}`, JSON.stringify({ password, name, phone, address }), 300000);
|
||||
try {
|
||||
await this.emailService.sendOTP(email, otp);
|
||||
return { success: true, message: 'Mã OTP đã được gửi đến email của bạn.' };
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[Signup OTP] Error:', error);
|
||||
throw new common_1.BadRequestException('Không thể gửi mã xác thực tới email này. Vui lòng kiểm tra lại cấu hình SMTP.');
|
||||
}
|
||||
}
|
||||
async signupVerify(body) {
|
||||
const { email, otp } = body;
|
||||
const storedOtp = await this.cacheManager.get(`signup_otp:${email}`);
|
||||
if (!storedOtp || storedOtp !== otp) {
|
||||
throw new common_1.BadRequestException('Mã OTP không chính xác hoặc đã hết hạn.');
|
||||
}
|
||||
const cachedDataStr = await this.cacheManager.get(`signup_data:${email}`);
|
||||
if (!cachedDataStr) {
|
||||
throw new common_1.BadRequestException('Thông tin đăng ký đã hết hạn. Vui lòng thực hiện lại từ đầu.');
|
||||
}
|
||||
const { password, name, phone, address } = JSON.parse(cachedDataStr);
|
||||
const userCount = await this.prisma.user.count();
|
||||
const shouldBeAdmin = userCount === 0;
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
return this.prisma.user.create({
|
||||
const user = await this.prisma.user.create({
|
||||
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
await Promise.all([
|
||||
this.cacheManager.del(`signup_otp:${email}`),
|
||||
this.cacheManager.del(`signup_data:${email}`)
|
||||
]);
|
||||
return user;
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
@@ -271,15 +360,26 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], AuthController.prototype, "login", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('signup'),
|
||||
(0, common_1.Post)('signup/request'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], AuthController.prototype, "signup", null);
|
||||
], AuthController.prototype, "signupRequest", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('signup/verify'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], AuthController.prototype, "signupVerify", null);
|
||||
AuthController = __decorate([
|
||||
(0, common_1.Controller)('auth'),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService, jwt_1.JwtService])
|
||||
__param(4, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
jwt_1.JwtService,
|
||||
config_1.ConfigService,
|
||||
EmailService, Object])
|
||||
], AuthController);
|
||||
let PublicTourController = class PublicTourController {
|
||||
constructor(prisma) {
|
||||
@@ -341,7 +441,7 @@ let TourController = class TourController {
|
||||
}
|
||||
async createTour(body, req) {
|
||||
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
|
||||
return this.prisma.tour.create({
|
||||
const tour = await this.prisma.tour.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
@@ -371,6 +471,8 @@ let TourController = class TourController {
|
||||
}
|
||||
}
|
||||
});
|
||||
await this.cacheManager.del(`/api/v1/tours/explore`);
|
||||
return tour;
|
||||
}
|
||||
async addLocation(tourId, body, req) {
|
||||
const legId = body.legId;
|
||||
@@ -405,11 +507,16 @@ let TourController = class TourController {
|
||||
}
|
||||
});
|
||||
}
|
||||
await Promise.all([
|
||||
this.cacheManager.del(tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
|
||||
]);
|
||||
return loc;
|
||||
});
|
||||
}
|
||||
async updateTourStartPoint(tourId, body, req) {
|
||||
const { latitude, longitude, name } = body;
|
||||
const { latitude, longitude, name, plannedEnd } = body;
|
||||
console.log(`[USER ACTION] User ${req.user.id} set START point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||
await this.prisma.location.deleteMany({
|
||||
where: {
|
||||
@@ -431,11 +538,19 @@ let TourController = class TourController {
|
||||
type: 'MOVE',
|
||||
legId: firstLeg.id,
|
||||
plannedStart: new Date(0),
|
||||
plannedEnd: plannedEnd ? new Date(plannedEnd) : null,
|
||||
}
|
||||
}).then(async (loc) => {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
|
||||
]);
|
||||
return loc;
|
||||
});
|
||||
}
|
||||
async updateTourEndPoint(tourId, body, req) {
|
||||
const { latitude, longitude, name } = body;
|
||||
const { latitude, longitude, name, plannedStart } = body;
|
||||
console.log(`[USER ACTION] User ${req.user.id} set END point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||
await this.prisma.location.deleteMany({
|
||||
where: {
|
||||
@@ -456,8 +571,16 @@ let TourController = class TourController {
|
||||
longitude,
|
||||
type: 'MOVE',
|
||||
legId: lastLeg.id,
|
||||
plannedStart: plannedStart ? new Date(plannedStart) : null,
|
||||
plannedEnd: new Date(0),
|
||||
}
|
||||
}).then(async (loc) => {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
|
||||
]);
|
||||
return loc;
|
||||
});
|
||||
}
|
||||
async initializeLegs(tourId, body) {
|
||||
@@ -491,6 +614,11 @@ let TourController = class TourController {
|
||||
data: { legId: lastLeg.id }
|
||||
});
|
||||
}
|
||||
await Promise.all([
|
||||
this.cacheManager.del(tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
|
||||
]);
|
||||
return allLegs;
|
||||
}
|
||||
async addLeg(tourId, body) {
|
||||
@@ -506,6 +634,13 @@ let TourController = class TourController {
|
||||
sequence: tour.legs.length + 1,
|
||||
note: body.note || `Chặng ${tour.legs.length + 1}`
|
||||
}
|
||||
}).then(async (leg) => {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
|
||||
]);
|
||||
return leg;
|
||||
});
|
||||
}
|
||||
async updateTour(id, body) {
|
||||
@@ -521,6 +656,13 @@ let TourController = class TourController {
|
||||
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
||||
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined,
|
||||
},
|
||||
}).then(async (tour) => {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(id),
|
||||
this.cacheManager.del(`/api/v1/tours/${id}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${id}/public`),
|
||||
]);
|
||||
return tour;
|
||||
});
|
||||
}
|
||||
async deleteTour(id) {
|
||||
@@ -564,6 +706,7 @@ let TourController = class TourController {
|
||||
await this.prisma.tour.delete({
|
||||
where: { id },
|
||||
});
|
||||
await this.cacheManager.del(`/api/v1/tours/explore`);
|
||||
return { success: true };
|
||||
}
|
||||
async getPublicTours(req) {
|
||||
@@ -1013,10 +1156,11 @@ TourController = __decorate([
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService, Object])
|
||||
], TourController);
|
||||
let LocationController = class LocationController {
|
||||
constructor(prisma) {
|
||||
constructor(prisma, cacheManager) {
|
||||
this.prisma = prisma;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
async updateLocation(id, body) {
|
||||
async updateLocation(id, body, req) {
|
||||
const { expenseAmount, expenseCategory, ...data } = body;
|
||||
const location = await this.prisma.location.update({
|
||||
where: { id },
|
||||
@@ -1054,38 +1198,63 @@ let LocationController = class LocationController {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (req.tourId) {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(req.tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
|
||||
]);
|
||||
}
|
||||
return location;
|
||||
}
|
||||
async deleteLocation(id) {
|
||||
await this.prisma.location.delete({ where: { id } });
|
||||
async deleteLocation(id, req) {
|
||||
try {
|
||||
await this.prisma.location.delete({ where: { id } });
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
await this.cacheManager.del(`res-to-tour:${id}`);
|
||||
if (req.tourId) {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(req.tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
|
||||
]);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.Patch)(':id'),
|
||||
(0, common_1.UseGuards)(TourRoleGuard),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
__param(2, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object]),
|
||||
__metadata("design:paramtypes", [String, Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], LocationController.prototype, "updateLocation", null);
|
||||
__decorate([
|
||||
(0, common_1.Delete)(':id'),
|
||||
(0, common_1.UseGuards)(TourRoleGuard),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String]),
|
||||
__metadata("design:paramtypes", [String, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], LocationController.prototype, "deleteLocation", null);
|
||||
LocationController = __decorate([
|
||||
(0, common_1.Controller)('locations'),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
__param(1, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService, Object])
|
||||
], LocationController);
|
||||
let LegController = class LegController {
|
||||
constructor(prisma) {
|
||||
constructor(prisma, cacheManager) {
|
||||
this.prisma = prisma;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
async updateLeg(id, body) {
|
||||
async updateLeg(id, body, req) {
|
||||
return this.prisma.leg.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -1095,6 +1264,13 @@ let LegController = class LegController {
|
||||
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||
description: body.description,
|
||||
}
|
||||
}).then(async (leg) => {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(req.tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
|
||||
]);
|
||||
return leg;
|
||||
});
|
||||
}
|
||||
async deleteLeg(id) {
|
||||
@@ -1105,20 +1281,35 @@ let LegController = class LegController {
|
||||
if (leg?._count.locations && leg._count.locations > 0) {
|
||||
throw new common_1.BadRequestException('Không thể xóa chặng đang có địa điểm. Hãy xóa địa điểm trước.');
|
||||
}
|
||||
await this.prisma.leg.delete({ where: { id } });
|
||||
try {
|
||||
const deletedLeg = await this.prisma.leg.delete({ where: { id } });
|
||||
await this.cacheManager.del(`res-to-tour:${id}`);
|
||||
if (deletedLeg.tourId) {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(deletedLeg.tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${deletedLeg.tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${deletedLeg.tourId}/public`),
|
||||
]);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.Patch)(':id'),
|
||||
(0, common_1.UseGuards)(TourRoleGuard),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
__param(2, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object]),
|
||||
__metadata("design:paramtypes", [String, Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], LegController.prototype, "updateLeg", null);
|
||||
__decorate([
|
||||
(0, common_1.Delete)(':id'),
|
||||
(0, common_1.UseGuards)(TourRoleGuard),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String]),
|
||||
@@ -1128,7 +1319,8 @@ LegController = __decorate([
|
||||
(0, common_1.Controller)('legs'),
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
__param(1, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService, Object])
|
||||
], LegController);
|
||||
function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||
const p = 0.017453292519943295;
|
||||
@@ -1139,10 +1331,11 @@ function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||
return 12742 * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
let RoutingController = class RoutingController {
|
||||
constructor(prisma) {
|
||||
constructor(prisma, cacheManager) {
|
||||
this.prisma = prisma;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
async optimize(legId) {
|
||||
async optimize(legId, req) {
|
||||
const currentLeg = await this.prisma.leg.findUnique({
|
||||
where: { id: legId },
|
||||
});
|
||||
@@ -1214,6 +1407,13 @@ let RoutingController = class RoutingController {
|
||||
where: { legId },
|
||||
orderBy: { plannedStart: 'asc' }
|
||||
});
|
||||
if (req.tourId) {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(req.tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
|
||||
]);
|
||||
}
|
||||
return {
|
||||
locations: updatedLocations,
|
||||
totalDistance: parseFloat(totalDistance.toFixed(2))
|
||||
@@ -1222,16 +1422,19 @@ let RoutingController = class RoutingController {
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.Post)('optimize/:legId'),
|
||||
(0, common_1.UseGuards)(TourRoleGuard),
|
||||
__param(0, (0, common_1.Param)('legId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String]),
|
||||
__metadata("design:paramtypes", [String, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], RoutingController.prototype, "optimize", null);
|
||||
RoutingController = __decorate([
|
||||
(0, common_1.Controller)('routing'),
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
__param(1, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService, Object])
|
||||
], RoutingController);
|
||||
let PhotoController = class PhotoController {
|
||||
constructor(prisma) {
|
||||
@@ -1487,11 +1690,120 @@ CommentController = __decorate([
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
CommentGateway])
|
||||
], CommentController);
|
||||
let AdminOtpController = class AdminOtpController {
|
||||
constructor(prisma, emailService, cacheManager) {
|
||||
this.prisma = prisma;
|
||||
this.emailService = emailService;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
async sendOtpToUser(body, req) {
|
||||
const { email } = body;
|
||||
const clientIp = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
|
||||
const emailLimitKey = `rl:otp:email:${email}`;
|
||||
const ipLimitKey = `rl:otp:ip:${clientIp}`;
|
||||
const [isEmailLimited, isIpLimited] = await Promise.all([
|
||||
this.cacheManager.get(emailLimitKey),
|
||||
this.cacheManager.get(ipLimitKey)
|
||||
]);
|
||||
if (isEmailLimited || isIpLimited) {
|
||||
throw new common_1.HttpException('Thao tác quá nhanh. Vui lòng đợi 60 giây giữa mỗi lần yêu cầu gửi mã.', common_1.HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
throw new common_1.NotFoundException('Không tìm thấy người dùng với email này');
|
||||
}
|
||||
const otp = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
const otpCacheKey = `otp:${email}`;
|
||||
await this.cacheManager.set(otpCacheKey, otp, 300000);
|
||||
try {
|
||||
await this.emailService.sendOTP(email, otp);
|
||||
await Promise.all([
|
||||
this.cacheManager.set(emailLimitKey, true, 60000),
|
||||
this.cacheManager.set(ipLimitKey, true, 60000)
|
||||
]);
|
||||
return { success: true, message: `Đã gửi mã OTP tới email ${email} thành công.` };
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[Admin OTP] SMTP Error:', error);
|
||||
throw new common_1.BadRequestException('Lỗi cấu hình SMTP hoặc không thể kết nối tới máy chủ gửi mail');
|
||||
}
|
||||
}
|
||||
async verifyOtp(body) {
|
||||
const { email, otp } = body;
|
||||
const otpCacheKey = `otp:${email}`;
|
||||
const failCountKey = `otp_fails:${email}`;
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user)
|
||||
throw new common_1.NotFoundException('Người dùng không tồn tại');
|
||||
if (user.isBlocked) {
|
||||
throw new common_1.ForbiddenException('Tài khoản này hiện đang bị khóa. Vui lòng liên hệ quản trị viên.');
|
||||
}
|
||||
const storedOtp = await this.cacheManager.get(otpCacheKey);
|
||||
if (!storedOtp) {
|
||||
throw new common_1.BadRequestException('Mã OTP đã hết hạn hoặc không tồn tại. Vui lòng yêu cầu mã mới.');
|
||||
}
|
||||
if (storedOtp === otp) {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(otpCacheKey),
|
||||
this.cacheManager.del(failCountKey)
|
||||
]);
|
||||
return { success: true, message: 'Xác thực mã OTP thành công.' };
|
||||
}
|
||||
else {
|
||||
let fails = (await this.cacheManager.get(failCountKey)) || 0;
|
||||
fails++;
|
||||
if (fails >= MAX_FAILED_ATTEMPTS) {
|
||||
await this.prisma.user.update({
|
||||
where: { email },
|
||||
data: { isBlocked: true }
|
||||
});
|
||||
await this.cacheManager.del(failCountKey);
|
||||
await this.cacheManager.del(otpCacheKey);
|
||||
throw new common_1.ForbiddenException(`Bạn đã nhập sai quá ${MAX_FAILED_ATTEMPTS} lần. Tài khoản đã bị khóa để bảo mật.`);
|
||||
}
|
||||
else {
|
||||
await this.cacheManager.set(failCountKey, fails, 300000);
|
||||
throw new common_1.BadRequestException({
|
||||
message: `Mã OTP không chính xác. Bạn còn ${MAX_FAILED_ATTEMPTS - fails} lần thử.`,
|
||||
remainingAttempts: MAX_FAILED_ATTEMPTS - fails
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.Post)('send'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__param(1, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], AdminOtpController.prototype, "sendOtpToUser", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('verify'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], AdminOtpController.prototype, "verifyOtp", null);
|
||||
AdminOtpController = __decorate([
|
||||
(0, common_1.Controller)('admin/otp'),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard),
|
||||
__param(2, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
EmailService, Object])
|
||||
], AdminOtpController);
|
||||
let AppModule = class AppModule {
|
||||
};
|
||||
AppModule = __decorate([
|
||||
(0, common_1.Module)({
|
||||
imports: [
|
||||
config_1.ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: path.resolve(__dirname, '..', '..', '.env'),
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'production',
|
||||
}),
|
||||
cache_manager_1.CacheModule.registerAsync({
|
||||
isGlobal: true,
|
||||
useFactory: async () => ({
|
||||
@@ -1506,12 +1818,15 @@ AppModule = __decorate([
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController],
|
||||
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, AdminOtpController],
|
||||
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector, EmailService, config_1.ConfigService],
|
||||
exports: [prisma_service_1.PrismaService]
|
||||
})
|
||||
], AppModule);
|
||||
bootstrap().catch(err => {
|
||||
if (err.message.includes('DATABASE_URL')) {
|
||||
console.error('❌ Lỗi nghiêm trọng: Biến môi trường DATABASE_URL không được tải. Hãy chắc chắn rằng file .env tồn tại ở thư mục gốc của dự án và chứa giá trị này.');
|
||||
}
|
||||
console.error('💥 Lỗi khởi động Server:');
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -21,6 +21,7 @@
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^3.1.3",
|
||||
"@nestjs/common": "^11.1.27",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.27",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
@@ -33,6 +34,7 @@
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"nodemailer": "^9.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.12.0",
|
||||
|
||||
+367
-35
@@ -1,17 +1,13 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
// Sửa đường dẫn: lùi 2 cấp từ backend/src để ra root monorepo
|
||||
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
import * as fs from 'fs';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
|
||||
import 'reflect-metadata';
|
||||
import * as fs from 'fs';
|
||||
import * as zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import sharp from 'sharp';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject } from '@nestjs/common';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
@@ -21,6 +17,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ParticipantRole } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AdminGuard } from './auth/admin.guard';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { JwtStrategy } from './auth/jwt.strategy';
|
||||
@@ -47,14 +44,6 @@ const CACHE_TTL = {
|
||||
};
|
||||
|
||||
async function bootstrap() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('❌ DATABASE_URL không tồn tại trong biến môi trường! Kiểm tra file .env tại: ' + envPath);
|
||||
}
|
||||
|
||||
// Kiểm tra log xem biến môi trường đã nhận đúng chưa
|
||||
console.log('====================================');
|
||||
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||
console.log('====================================');
|
||||
|
||||
// Chuyển sang dùng NestExpressApplication để cấu hình static assets
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
@@ -178,10 +167,79 @@ export class TourRoleGuard implements CanActivate {
|
||||
if (!rolesToCheck.some(r => role === r)) {
|
||||
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này trong tour này.');
|
||||
}
|
||||
|
||||
// Ensure tourId is attached to the request object for controllers to use
|
||||
if (tourId) {
|
||||
(request as any).tourId = tourId;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private transporter: any;
|
||||
|
||||
constructor() {
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
|
||||
if (!user || !pass) {
|
||||
console.warn('[EmailService] ⚠️ SMTP_USER hoặc SMTP_PASS chưa được cấu hình trong file .env. Tính năng gửi mã OTP sẽ không khả dụng.');
|
||||
} else {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_SECURE === 'true', // true cho cổng 465, false cho cổng 587/25
|
||||
auth: {
|
||||
user: user,
|
||||
pass: pass,
|
||||
},
|
||||
});
|
||||
|
||||
// Tự động kiểm tra kết nối khi khởi tạo để phát hiện lỗi cấu hình sớm
|
||||
this.transporter.verify((error: any) => {
|
||||
if (error) {
|
||||
console.error('[EmailService] ❌ Lỗi kết nối SMTP:', error.message);
|
||||
} else {
|
||||
console.log('[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendOTP(email: string, otp: string) {
|
||||
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
|
||||
throw new Error('Thiếu cấu hình SMTP_USER hoặc SMTP_PASS trong file .env');
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: `"Travel Planning Support" <${process.env.SMTP_USER}>`,
|
||||
to: email,
|
||||
subject: 'Mã OTP Xác Thực Hệ Thống',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 10px;">
|
||||
<h2 style="color: #2563eb; text-align: center;">Mã Xác Thực OTP</h2>
|
||||
<p>Xin chào,</p>
|
||||
<p>Quản trị viên hệ thống đã yêu cầu cấp và gửi mã OTP xác thực tới tài khoản của bạn. Vui lòng sử dụng mã bảo mật dưới đây:</p>
|
||||
<div style="background-color: #f3f4f6; padding: 15px; text-align: center; font-size: 24px; font-weight: bold; letter-spacing: 5px; color: #1e3a8a; margin: 20px 0; border-radius: 5px;">
|
||||
${otp}
|
||||
</div>
|
||||
<p>Mã OTP này có hiệu lực trong vòng 5 phút. Vui lòng tuyệt đối không chia sẻ mã này cho bất kỳ ai khác.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;" />
|
||||
<p style="font-size: 12px; color: #6b7280; text-align: center;">Đây là email tự động từ ứng dụng hệ thống Travel Planning.</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
try {
|
||||
return await this.transporter.sendMail(mailOptions);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Controller()
|
||||
class AppController {
|
||||
@Get()
|
||||
@@ -192,7 +250,17 @@ class AppController {
|
||||
|
||||
@Controller('auth')
|
||||
class AuthController {
|
||||
constructor(private prisma: PrismaService, private jwtService: JwtService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
// Inject ConfigService để đọc biến môi trường một cách an toàn
|
||||
// NestJS sẽ đảm bảo ConfigModule được tải trước khi AuthController được khởi tạo
|
||||
// Do đó, các biến môi trường sẽ luôn có sẵn ở đây.
|
||||
// Điều này cũng áp dụng cho EmailService.
|
||||
private configService: ConfigService,
|
||||
private emailService: EmailService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache
|
||||
) {}
|
||||
|
||||
@Get('status')
|
||||
async getStatus() {
|
||||
@@ -228,22 +296,60 @@ class AuthController {
|
||||
};
|
||||
}
|
||||
|
||||
@Post('signup')
|
||||
async signup(@Body() body: any) {
|
||||
@Post('signup/request')
|
||||
async signupRequest(@Body() body: any) {
|
||||
const { email, password, name, phone, address } = body;
|
||||
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existingUser) throw new BadRequestException('Email đã được sử dụng');
|
||||
|
||||
const otp = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
|
||||
// Lưu OTP và dữ liệu form đăng ký vào Cache trong vòng 5 phút (300000 ms)
|
||||
await this.cacheManager.set(`signup_otp:${email}`, otp, 300000);
|
||||
await this.cacheManager.set(`signup_data:${email}`, JSON.stringify({ password, name, phone, address }), 300000);
|
||||
|
||||
try {
|
||||
await this.emailService.sendOTP(email, otp);
|
||||
return { success: true, message: 'Mã OTP đã được gửi đến email của bạn.' };
|
||||
} catch (error) {
|
||||
console.error('[Signup OTP] Error:', error);
|
||||
throw new BadRequestException('Không thể gửi mã xác thực tới email này. Vui lòng kiểm tra lại cấu hình SMTP.');
|
||||
}
|
||||
}
|
||||
|
||||
@Post('signup/verify')
|
||||
async signupVerify(@Body() body: { email: string; otp: string }) {
|
||||
const { email, otp } = body;
|
||||
|
||||
const storedOtp = await this.cacheManager.get<string>(`signup_otp:${email}`);
|
||||
if (!storedOtp || storedOtp !== otp) {
|
||||
throw new BadRequestException('Mã OTP không chính xác hoặc đã hết hạn.');
|
||||
}
|
||||
|
||||
const cachedDataStr = await this.cacheManager.get<string>(`signup_data:${email}`);
|
||||
if (!cachedDataStr) {
|
||||
throw new BadRequestException('Thông tin đăng ký đã hết hạn. Vui lòng thực hiện lại từ đầu.');
|
||||
}
|
||||
|
||||
const { password, name, phone, address } = JSON.parse(cachedDataStr);
|
||||
const userCount = await this.prisma.user.count();
|
||||
const shouldBeAdmin = userCount === 0;
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
return this.prisma.user.create({
|
||||
const user = await this.prisma.user.create({
|
||||
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
|
||||
// Xóa dữ liệu cache sau khi đăng ký thành công
|
||||
await Promise.all([
|
||||
this.cacheManager.del(`signup_otp:${email}`),
|
||||
this.cacheManager.del(`signup_data:${email}`)
|
||||
]);
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +409,7 @@ class TourController {
|
||||
@Post()
|
||||
async createTour(@Body() body: any, @Req() req: any) {
|
||||
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
|
||||
return this.prisma.tour.create({
|
||||
const tour = await this.prisma.tour.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
@@ -333,6 +439,8 @@ class TourController {
|
||||
}
|
||||
}
|
||||
});
|
||||
await this.cacheManager.del(`/api/v1/tours/explore`);
|
||||
return tour;
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER) // Allow members to add locations
|
||||
@@ -350,6 +458,7 @@ class TourController {
|
||||
if (!leg) throw new NotFoundException('Không tìm thấy chặng phù hợp để thêm địa điểm');
|
||||
|
||||
return this.prisma.location.create({
|
||||
// ...
|
||||
data: {
|
||||
name: body.name,
|
||||
address: body.address,
|
||||
@@ -374,7 +483,13 @@ class TourController {
|
||||
}
|
||||
});
|
||||
}
|
||||
return loc;
|
||||
// Xóa triệt để các loại cache của Tour (cả key UUID và key URL của Interceptor)
|
||||
await Promise.all([
|
||||
this.cacheManager.del(tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
|
||||
]);
|
||||
return loc; // Return the created location
|
||||
});
|
||||
}
|
||||
|
||||
@@ -382,7 +497,7 @@ class TourController {
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/start-point')
|
||||
async updateTourStartPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
|
||||
const { latitude, longitude, name } = body;
|
||||
const { latitude, longitude, name, plannedEnd } = body;
|
||||
|
||||
console.log(`[USER ACTION] User ${req.user.id} set START point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||
|
||||
@@ -412,7 +527,15 @@ class TourController {
|
||||
type: 'MOVE',
|
||||
legId: firstLeg.id,
|
||||
plannedStart: new Date(0),
|
||||
plannedEnd: plannedEnd ? new Date(plannedEnd) : null,
|
||||
}
|
||||
}).then(async (loc) => {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
|
||||
]);
|
||||
return loc;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -420,7 +543,7 @@ class TourController {
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/end-point')
|
||||
async updateTourEndPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
|
||||
const { latitude, longitude, name } = body;
|
||||
const { latitude, longitude, name, plannedStart } = body;
|
||||
|
||||
console.log(`[USER ACTION] User ${req.user.id} set END point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||
|
||||
@@ -447,8 +570,16 @@ class TourController {
|
||||
longitude,
|
||||
type: 'MOVE',
|
||||
legId: lastLeg.id,
|
||||
plannedStart: plannedStart ? new Date(plannedStart) : null,
|
||||
plannedEnd: new Date(0), // Đánh dấu đây là điểm kết thúc đặc biệt
|
||||
}
|
||||
}).then(async (loc) => {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
|
||||
]);
|
||||
return loc;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -495,7 +626,13 @@ class TourController {
|
||||
});
|
||||
}
|
||||
|
||||
return allLegs;
|
||||
// Invalidate cache for the tour after initializing legs
|
||||
await Promise.all([
|
||||
this.cacheManager.del(tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
|
||||
]);
|
||||
return allLegs; // Return all legs
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add legs
|
||||
@@ -514,6 +651,13 @@ class TourController {
|
||||
sequence: tour.legs.length + 1,
|
||||
note: body.note || `Chặng ${tour.legs.length + 1}`
|
||||
}
|
||||
}).then(async (leg) => {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
|
||||
]);
|
||||
return leg;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -534,6 +678,13 @@ class TourController {
|
||||
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
||||
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined,
|
||||
},
|
||||
}).then(async (tour) => {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(id),
|
||||
this.cacheManager.del(`/api/v1/tours/${id}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${id}/public`),
|
||||
]);
|
||||
return tour;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -595,6 +746,10 @@ class TourController {
|
||||
await this.prisma.tour.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
// Xóa cache explore để Tour biến mất ngay lập tức trên bản đồ cộng đồng
|
||||
await this.cacheManager.del(`/api/v1/tours/explore`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -938,10 +1093,11 @@ class TourController {
|
||||
@Controller('locations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
class LocationController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache) {}
|
||||
|
||||
@Patch(':id')
|
||||
async updateLocation(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
|
||||
@UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId
|
||||
async updateLocation(@Param('id', ParseUUIDPipe) id: string, @Body() body: any, @Req() req: any) {
|
||||
const { expenseAmount, expenseCategory, ...data } = body;
|
||||
|
||||
const location = await this.prisma.location.update({
|
||||
@@ -982,12 +1138,35 @@ class LocationController {
|
||||
}
|
||||
}
|
||||
|
||||
if (req.tourId) {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(req.tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
|
||||
]);
|
||||
}
|
||||
return location;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteLocation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.prisma.location.delete({ where: { id } });
|
||||
@UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId
|
||||
async deleteLocation(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||
try {
|
||||
await this.prisma.location.delete({ where: { id } });
|
||||
} catch (e) {
|
||||
// Nếu bản ghi đã bị xóa trước đó, không ném lỗi 500 để đảm bảo tính an toàn (idempotency)
|
||||
}
|
||||
|
||||
// Xóa mapping cache để Guard không bị đánh lừa ở lần truy cập sau
|
||||
await this.cacheManager.del(`res-to-tour:${id}`);
|
||||
|
||||
if (req.tourId) {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(req.tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
|
||||
]);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -996,10 +1175,11 @@ class LocationController {
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Leg operations
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
class LegController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache) {}
|
||||
|
||||
@Patch(':id')
|
||||
async updateLeg(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
|
||||
@UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId
|
||||
async updateLeg(@Param('id', ParseUUIDPipe) id: string, @Body() body: any, @Req() req: any) {
|
||||
return this.prisma.leg.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -1009,10 +1189,18 @@ class LegController {
|
||||
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||
description: body.description,
|
||||
}
|
||||
}).then(async (leg) => {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(req.tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
|
||||
]);
|
||||
return leg;
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId
|
||||
async deleteLeg(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const leg = await this.prisma.leg.findUnique({
|
||||
where: { id },
|
||||
@@ -1023,7 +1211,21 @@ class LegController {
|
||||
throw new BadRequestException('Không thể xóa chặng đang có địa điểm. Hãy xóa địa điểm trước.');
|
||||
}
|
||||
|
||||
await this.prisma.leg.delete({ where: { id } });
|
||||
try {
|
||||
const deletedLeg = await this.prisma.leg.delete({ where: { id } });
|
||||
await this.cacheManager.del(`res-to-tour:${id}`);
|
||||
|
||||
if (deletedLeg.tourId) {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(deletedLeg.tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${deletedLeg.tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${deletedLeg.tourId}/public`),
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
// Idempotency
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -1044,10 +1246,11 @@ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: numbe
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Routing operations
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
class RoutingController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache) {}
|
||||
|
||||
@Post('optimize/:legId')
|
||||
async optimize(@Param('legId', ParseUUIDPipe) legId: string) {
|
||||
@UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId
|
||||
async optimize(@Param('legId', ParseUUIDPipe) legId: string, @Req() req: any) {
|
||||
const currentLeg = await this.prisma.leg.findUnique({
|
||||
where: { id: legId },
|
||||
});
|
||||
@@ -1148,6 +1351,13 @@ class RoutingController {
|
||||
orderBy: { plannedStart: 'asc' }
|
||||
});
|
||||
|
||||
if (req.tourId) {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(req.tourId),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
|
||||
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
|
||||
]);
|
||||
}
|
||||
return {
|
||||
locations: updatedLocations,
|
||||
totalDistance: parseFloat(totalDistance.toFixed(2))
|
||||
@@ -1371,8 +1581,127 @@ class CommentController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/otp')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
class AdminOtpController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailService: EmailService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache
|
||||
) {}
|
||||
|
||||
@Post('send')
|
||||
async sendOtpToUser(@Body() body: { email: string }, @Req() req: any) {
|
||||
const { email } = body;
|
||||
|
||||
// 1. Xác định định danh: IP và Email
|
||||
const clientIp = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
|
||||
const emailLimitKey = `rl:otp:email:${email}`;
|
||||
const ipLimitKey = `rl:otp:ip:${clientIp}`;
|
||||
|
||||
// 2. Kiểm tra xem IP hoặc Email này có đang bị giới hạn không (ví dụ: 1 phút 1 lần)
|
||||
const [isEmailLimited, isIpLimited] = await Promise.all([
|
||||
this.cacheManager.get(emailLimitKey),
|
||||
this.cacheManager.get(ipLimitKey)
|
||||
]);
|
||||
|
||||
if (isEmailLimited || isIpLimited) {
|
||||
throw new HttpException(
|
||||
'Thao tác quá nhanh. Vui lòng đợi 60 giây giữa mỗi lần yêu cầu gửi mã.',
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('Không tìm thấy người dùng với email này');
|
||||
}
|
||||
|
||||
const otp = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
const otpCacheKey = `otp:${email}`;
|
||||
|
||||
await this.cacheManager.set(otpCacheKey, otp, 300000);
|
||||
|
||||
try {
|
||||
await this.emailService.sendOTP(email, otp);
|
||||
|
||||
// 3. Thiết lập khóa chặn sau khi gửi thành công (hết hạn sau 60 giây)
|
||||
await Promise.all([
|
||||
this.cacheManager.set(emailLimitKey, true, 60000),
|
||||
this.cacheManager.set(ipLimitKey, true, 60000)
|
||||
]);
|
||||
|
||||
return { success: true, message: `Đã gửi mã OTP tới email ${email} thành công.` };
|
||||
} catch (error) {
|
||||
console.error('[Admin OTP] SMTP Error:', error);
|
||||
throw new BadRequestException('Lỗi cấu hình SMTP hoặc không thể kết nối tới máy chủ gửi mail');
|
||||
}
|
||||
}
|
||||
|
||||
@Post('verify')
|
||||
async verifyOtp(@Body() body: { email: string; otp: string }) {
|
||||
const { email, otp } = body;
|
||||
const otpCacheKey = `otp:${email}`;
|
||||
const failCountKey = `otp_fails:${email}`;
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
|
||||
// 1. Kiểm tra tài khoản có đang bị khóa không
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) throw new NotFoundException('Người dùng không tồn tại');
|
||||
if (user.isBlocked) {
|
||||
throw new ForbiddenException('Tài khoản này hiện đang bị khóa. Vui lòng liên hệ quản trị viên.');
|
||||
}
|
||||
|
||||
// 2. Lấy OTP từ Cache
|
||||
const storedOtp = await this.cacheManager.get<string>(otpCacheKey);
|
||||
if (!storedOtp) {
|
||||
throw new BadRequestException('Mã OTP đã hết hạn hoặc không tồn tại. Vui lòng yêu cầu mã mới.');
|
||||
}
|
||||
|
||||
// 3. So sánh mã OTP
|
||||
if (storedOtp === otp) {
|
||||
// Thành công: Xóa OTP và bộ đếm lỗi
|
||||
await Promise.all([
|
||||
this.cacheManager.del(otpCacheKey),
|
||||
this.cacheManager.del(failCountKey)
|
||||
]);
|
||||
return { success: true, message: 'Xác thực mã OTP thành công.' };
|
||||
} else {
|
||||
// Thất bại: Tăng bộ đếm lỗi
|
||||
let fails: number = (await this.cacheManager.get<number>(failCountKey)) || 0;
|
||||
fails++;
|
||||
|
||||
if (fails >= MAX_FAILED_ATTEMPTS) {
|
||||
// Khóa tài khoản trong DB
|
||||
await this.prisma.user.update({
|
||||
where: { email },
|
||||
data: { isBlocked: true }
|
||||
});
|
||||
await this.cacheManager.del(failCountKey);
|
||||
await this.cacheManager.del(otpCacheKey);
|
||||
|
||||
throw new ForbiddenException(`Bạn đã nhập sai quá ${MAX_FAILED_ATTEMPTS} lần. Tài khoản đã bị khóa để bảo mật.`);
|
||||
} else {
|
||||
// Cập nhật số lần sai vào cache (TTL 5 phút bằng với OTP)
|
||||
await this.cacheManager.set(failCountKey, fails, 300000);
|
||||
throw new BadRequestException({
|
||||
message: `Mã OTP không chính xác. Bạn còn ${MAX_FAILED_ATTEMPTS - fails} lần thử.`,
|
||||
remainingAttempts: MAX_FAILED_ATTEMPTS - fails
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // Giúp ConfigModule có sẵn ở mọi nơi trong ứng dụng
|
||||
// Chỉ định đường dẫn tới file .env ở thư mục gốc của dự án
|
||||
envFilePath: path.resolve(__dirname, '..', '..', '.env'),
|
||||
// Bỏ qua lỗi nếu không tìm thấy file .env (hữu ích cho môi trường production dùng biến hệ thống)
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'production',
|
||||
}),
|
||||
CacheModule.registerAsync({
|
||||
isGlobal: true,
|
||||
useFactory: async () => ({
|
||||
@@ -1387,14 +1716,17 @@ class CommentController {
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}) as any,
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, AdminOtpController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
class AppModule {}
|
||||
|
||||
|
||||
bootstrap().catch(err => {
|
||||
if (err.message.includes('DATABASE_URL')) {
|
||||
console.error('❌ Lỗi nghiêm trọng: Biến môi trường DATABASE_URL không được tải. Hãy chắc chắn rằng file .env tồn tại ở thư mục gốc của dự án và chứa giá trị này.');
|
||||
}
|
||||
console.error('💥 Lỗi khởi động Server:');
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
|
||||
@@ -2,8 +2,9 @@ import React, { useState, useEffect } from 'react';
|
||||
import { LandingPage } from './pages/LandingPage';
|
||||
import { ExploreMap } from './pages/ExploreMap';
|
||||
import { TourDetailPage } from './pages/TourDetailPage';
|
||||
import { SignupPage } from './pages/SignupPage';
|
||||
import SignupPage from './pages/SignupPage';
|
||||
import { MyPhotosPage } from './pages/MyPhotosPage';
|
||||
import { MyNotePage } from './pages/MyNotePage';
|
||||
import { useTourStore } from './store/useTourStore';
|
||||
import { ConfirmProvider } from './hooks/useConfirm';
|
||||
import { NotificationProvider } from './hooks/useNotification';
|
||||
@@ -13,7 +14,7 @@ function App() {
|
||||
const viewTourId = params.get('viewTour');
|
||||
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos'>(viewTourId ? 'tourDetail' : 'landing');
|
||||
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes'>(viewTourId ? 'tourDetail' : 'landing');
|
||||
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
||||
|
||||
@@ -94,10 +95,15 @@ function App() {
|
||||
tourId={currentTourId!}
|
||||
onBack={handleBackFromTourDetail}
|
||||
isPublicView={isPublicTourView}
|
||||
onOpenNotes={() => setCurrentPage('notes')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPage === 'notes') {
|
||||
return <MyNotePage onBack={() => setCurrentPage('tourDetail')} />;
|
||||
}
|
||||
|
||||
if (currentPage === 'explore') {
|
||||
return (
|
||||
<ExploreMap
|
||||
|
||||
@@ -61,7 +61,7 @@ const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, cen
|
||||
);
|
||||
};
|
||||
|
||||
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation, isPublicView = false }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isPublicView?: boolean }) => {
|
||||
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation, isStartPoint = false, isEndPoint = false, isPublicView = false, onSuccess }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isStartPoint?: boolean, isEndPoint?: boolean, isPublicView?: boolean, onSuccess?: () => void }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
address: '',
|
||||
@@ -85,7 +85,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
const searchTimeout = useRef<any>(null);
|
||||
|
||||
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
||||
const { legs, addLocation, updateLocation, mapCenter, currentTour, userRole } = useTourStore();
|
||||
const { legs, addLocation, updateLocation, updateTourStartPoint, updateTourEndPoint, mapCenter, currentTour, userRole } = useTourStore();
|
||||
const notify = useNotification();
|
||||
|
||||
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
|
||||
@@ -108,7 +108,9 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
expenseDescription: expense?.description || '',
|
||||
expenseNote: expense?.note || '',
|
||||
paidById: expense?.paidById || '',
|
||||
plannedStart: editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : '',
|
||||
plannedStart: isStartPoint
|
||||
? (editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : '')
|
||||
: (editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : ''),
|
||||
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
|
||||
});
|
||||
} else {
|
||||
@@ -153,8 +155,13 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
// 2. Thực hiện các tính toán và hàm xử lý
|
||||
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
|
||||
const targetLeg = legs.find(l => l.id === currentLegId);
|
||||
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
|
||||
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
||||
const titleText = isStartPoint ? 'Thiết lập Điểm xuất phát' :
|
||||
isEndPoint ? 'Thiết lập Điểm kết thúc' :
|
||||
editingLocation ? `Sửa địa điểm: ${editingLocation.name}` :
|
||||
(initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
|
||||
const buttonText = isStartPoint ? 'Xác nhận Điểm xuất phát' :
|
||||
isEndPoint ? 'Xác nhận Điểm kết thúc' :
|
||||
editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
||||
|
||||
const handleSearchLocation = (query: string) => {
|
||||
setFormData(prev => ({ ...prev, name: query }));
|
||||
@@ -281,19 +288,41 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
...formData,
|
||||
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
|
||||
legId: currentLegId,
|
||||
latitude: parseFloat(formData.latitude as any),
|
||||
longitude: parseFloat(formData.longitude as any),
|
||||
};
|
||||
|
||||
if (editingLocation) {
|
||||
await updateLocation(editingLocation.id, payload);
|
||||
if (isStartPoint) {
|
||||
await updateTourStartPoint(tourId, {
|
||||
name: formData.name || "Điểm xuất phát",
|
||||
latitude: parseFloat(formData.latitude as any),
|
||||
longitude: parseFloat(formData.longitude as any),
|
||||
plannedEnd: formData.plannedStart,
|
||||
});
|
||||
} else if (isEndPoint) {
|
||||
await updateTourEndPoint(tourId, {
|
||||
name: formData.name || "Điểm kết thúc",
|
||||
latitude: parseFloat(formData.latitude as any),
|
||||
longitude: parseFloat(formData.longitude as any),
|
||||
plannedStart: formData.plannedStart,
|
||||
});
|
||||
} else {
|
||||
await addLocation(tourId, payload);
|
||||
const payload: any = {
|
||||
...formData,
|
||||
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
|
||||
legId: currentLegId,
|
||||
latitude: parseFloat(formData.latitude as any),
|
||||
longitude: parseFloat(formData.longitude as any),
|
||||
};
|
||||
|
||||
if (editingLocation) {
|
||||
await updateLocation(editingLocation.id, payload);
|
||||
} else {
|
||||
await addLocation(tourId, payload);
|
||||
}
|
||||
}
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: isStartPoint ? 'Đã thiết lập điểm xuất phát.' : isEndPoint ? 'Đã thiết lập điểm kết thúc.' : editingLocation ? 'Đã cập nhật địa điểm.' : 'Đã thêm địa điểm mới.',
|
||||
type: 'success'
|
||||
});
|
||||
onSuccess?.(); // Gọi callback onSuccess sau khi thành công
|
||||
onClose();
|
||||
} catch (error) {
|
||||
notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' });
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
@@ -26,7 +26,7 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const confirm = useConfirm();
|
||||
const [confirmState, setConfirmState] = useState({ open: false, commentId: '' });
|
||||
|
||||
const userRole = useTourStore(state => state.userRole);
|
||||
const currentUserId = React.useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare } from 'lucide-react';
|
||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare, FileText, Flag } from 'lucide-react';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
@@ -41,8 +41,18 @@ const formatTravelTime = (minutes: number) => {
|
||||
export const ItineraryTimeline = ({
|
||||
onAddLocation,
|
||||
onEditLocation,
|
||||
onQuickNote,
|
||||
onNavigate,
|
||||
onSuccess,
|
||||
isPublicView = false
|
||||
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void, isPublicView?: boolean }) => {
|
||||
}: {
|
||||
onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void,
|
||||
onEditLocation?: (location: any) => void,
|
||||
onQuickNote?: (name: string) => void,
|
||||
onNavigate?: (location: any) => void,
|
||||
onSuccess?: () => void,
|
||||
isPublicView?: boolean
|
||||
}) => {
|
||||
// Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết
|
||||
const currentTour = useTourStore(state => state.currentTour);
|
||||
const legs = useTourStore(state => state.legs);
|
||||
@@ -105,9 +115,10 @@ export const ItineraryTimeline = ({
|
||||
endDate: ''
|
||||
});
|
||||
|
||||
const toggleComplete = async (locationId: string) => {
|
||||
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
|
||||
console.log("Toggle status for location:", locationId);
|
||||
const handleStatusClick = (location: any) => {
|
||||
if (onNavigate) {
|
||||
onNavigate(location);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLeg = async () => {
|
||||
@@ -160,6 +171,7 @@ export const ItineraryTimeline = ({
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
await deleteLeg(legId);
|
||||
onSuccess?.();
|
||||
} catch (err: any) {
|
||||
notify({ title: 'Lỗi', message: err.message, type: 'error' });
|
||||
}
|
||||
@@ -174,12 +186,20 @@ export const ItineraryTimeline = ({
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
await deleteLocation(id);
|
||||
onSuccess?.();
|
||||
} catch (err: any) {
|
||||
notify({ title: 'Lỗi', message: err.message, type: 'error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Tạo mảng phẳng tất cả địa điểm để tính toán quãng đường liên tục giữa các chặng
|
||||
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("ItineraryTimeline: Legs updated", legs);
|
||||
}, [legs]);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
||||
<div className="px-2 pt-4">
|
||||
@@ -284,24 +304,69 @@ export const ItineraryTimeline = ({
|
||||
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
|
||||
|
||||
<div className="ml-2">
|
||||
{/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
|
||||
{legIdx === 0 && !leg.locations.some(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
|
||||
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
|
||||
<div className="z-10 mt-1.5 mr-4">
|
||||
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-blue-200 flex items-center justify-center text-blue-400">
|
||||
<MapPin className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAddLocation?.(leg.id, true)}
|
||||
className="flex-1 bg-blue-50/20 p-4 rounded-xl border border-dashed border-blue-100 hover:border-blue-400 hover:bg-blue-50 transition-all flex items-center justify-between group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<span className="inline-block px-2 py-0.5 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm xuất phát</span>
|
||||
<h3 className="font-bold text-gray-400 text-sm italic">Nhấn để ghim điểm bắt đầu cho Tour...</h3>
|
||||
</div>
|
||||
<Plus className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nút thêm nhanh "Điểm kết thúc" cho Chặng cuối nếu chưa có */}
|
||||
{legIdx === legs.length - 1 && !legs.some(l => l.locations.some(loc => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0)) && (
|
||||
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
|
||||
<div className="z-10 mt-1.5 mr-4">
|
||||
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-red-200 flex items-center justify-center text-red-400">
|
||||
<Flag className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAddLocation?.(leg.id, false, true)}
|
||||
className="flex-1 bg-red-50/20 p-4 rounded-xl border border-dashed border-red-100 hover:border-red-400 hover:bg-red-50 transition-all flex items-center justify-between group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
||||
<h3 className="font-bold text-gray-400 text-sm italic">Nhấn để ghim điểm kết thúc cho Tour...</h3>
|
||||
</div>
|
||||
<Plus className="w-5 h-5 text-red-500 group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leg.locations.map((location, idx) => {
|
||||
// Logic quan trọng: Gán điểm cuối chặng này nối với điểm đầu chặng sau
|
||||
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
|
||||
const distanceToNext = nextLocation
|
||||
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
||||
: null;
|
||||
// Tìm vị trí của điểm này trong toàn bộ hành trình
|
||||
const globalIdx = allLocations.findIndex(loc => loc.id === location.id);
|
||||
const prevLocation = globalIdx > 0 ? allLocations[globalIdx - 1] : null;
|
||||
|
||||
const averageSpeed = 35; // km/h
|
||||
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
||||
|
||||
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||
const distanceFromPrev = prevLocation
|
||||
? calculateDistance(prevLocation.latitude, prevLocation.longitude, location.latitude, location.longitude)
|
||||
: null;
|
||||
|
||||
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
|
||||
|
||||
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||
// Nhận diện điểm mốc dựa trên timestamp đặc biệt (0) thay vì chỉ số mảng
|
||||
const isStartPoint = location.plannedStart && new Date(location.plannedStart).getTime() === 0;
|
||||
const isEndPoint = location.plannedEnd && new Date(location.plannedEnd).getTime() === 0;
|
||||
|
||||
const plannedTimeStr = isStartPoint ? location.plannedEnd : location.plannedStart;
|
||||
const hasValidPlannedTime = plannedTimeStr && new Date(plannedTimeStr).getTime() !== 0;
|
||||
|
||||
const dwellMinutes = (location.plannedStart && location.plannedEnd && !isStartPoint && !isEndPoint)
|
||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={location.id}>
|
||||
@@ -309,7 +374,7 @@ export const ItineraryTimeline = ({
|
||||
{/* Timeline Node */}
|
||||
<div className="z-10 mt-1.5 mr-4">
|
||||
<button
|
||||
onClick={() => toggleComplete(location.id)}
|
||||
onClick={() => handleStatusClick(location)}
|
||||
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
||||
>
|
||||
{location.status === 'COMPLETED' ? (
|
||||
@@ -370,27 +435,38 @@ export const ItineraryTimeline = ({
|
||||
</div>
|
||||
|
||||
<div className="text-right flex flex-col items-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setCommentLocationId(location.id);
|
||||
setCommentLocationName(location.name);
|
||||
setIsCommentModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100 mb-2"
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
BÌNH LUẬN {location._count?.comments > 0 && `(${location._count.comments})`}
|
||||
</button>
|
||||
<div className="flex gap-1 mb-2">
|
||||
{onQuickNote && !isPublicView && (
|
||||
<button
|
||||
onClick={() => onQuickNote(location.name)}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-bold transition-all border border-amber-100"
|
||||
title="Ghi chú nhanh"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setCommentLocationId(location.id);
|
||||
setCommentLocationName(location.name);
|
||||
setIsCommentModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100"
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{location._count?.comments > 0 && `(${location._count.comments})`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center text-sm font-black text-blue-600">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
|
||||
{hasValidPlannedTime ? format(parseISO(plannedTimeStr), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
{location.status === 'COMPLETED' && location.actualStart && (
|
||||
<div className="text-[10px] text-gray-400 mt-1 italic">
|
||||
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && !isStartPoint && !isEndPoint && ( // Only show edit/delete if canEdit
|
||||
{canEdit && ( // Allow editing and deleting of all locations if user has edit permissions
|
||||
<div className="flex gap-1 mt-2">
|
||||
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
@@ -404,22 +480,17 @@ export const ItineraryTimeline = ({
|
||||
</div>
|
||||
|
||||
{/* Logic tính toán độ lệch thời gian */}
|
||||
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} />
|
||||
<TimeVariance planned={hasValidPlannedTime ? plannedTimeStr : ''} actual={location.actualStart || null} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{distanceToNext !== null && travelTimeMinutes !== null && (
|
||||
<div className="ml-4 -mt-4 mb-2 flex items-center gap-3">
|
||||
<div className="w-8 flex justify-center">
|
||||
<Navigation className="w-3 h-3 text-blue-400 rotate-180" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100">
|
||||
{distanceToNext.toFixed(2)} km
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
~ {formatTravelTime(travelTimeMinutes)}
|
||||
{/* Hiển thị quãng đường di chuyển từ điểm trước ĐẾN điểm hiện tại */}
|
||||
{distanceFromPrev !== null && distanceFromPrev > 0 && (
|
||||
<div className="ml-14 -mt-4 mb-6 flex items-center gap-2 animate-in fade-in slide-in-from-left-2 duration-500">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-xl border border-blue-100 shadow-sm">
|
||||
<Navigation className="w-3 h-3 rotate-45" />
|
||||
<span className="text-[10px] font-black uppercase tracking-tighter">
|
||||
+{distanceFromPrev.toFixed(1)} km từ {prevLocation?.name.split(',')[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -223,7 +223,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
<div className="flex items-center gap-3 pointer-events-auto">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100"
|
||||
className="w-11 h-11 flex items-center justify-center bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 shrink-0"
|
||||
title="Quay lại"
|
||||
>
|
||||
<X className="w-6 h-6 text-gray-800" />
|
||||
@@ -233,7 +233,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsFilterDropdownOpen(prev => !prev)}
|
||||
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center"
|
||||
className="w-11 h-11 bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center shrink-0"
|
||||
title="Lọc theo loại"
|
||||
>
|
||||
<Filter className="w-6 h-6 text-gray-800" />
|
||||
@@ -318,7 +318,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
console.log("Đang mở Ảnh của tôi...");
|
||||
onOpenMyPhotos();
|
||||
}}
|
||||
className="bg-white p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-50 text-blue-600 transition-all flex items-center gap-2 font-bold border border-blue-100"
|
||||
className="w-11 h-11 md:w-auto bg-white p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-50 text-blue-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold border border-blue-100 shrink-0"
|
||||
title="Ảnh của tôi"
|
||||
>
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
@@ -330,7 +330,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="bg-green-600 p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold"
|
||||
className="w-11 h-11 md:w-auto bg-green-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
|
||||
title="Tạo Tour mới"
|
||||
>
|
||||
<Navigation className="w-5 h-5" />
|
||||
@@ -342,7 +342,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsAdminModalOpen(true)}
|
||||
className="bg-blue-600 p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold"
|
||||
className="w-11 h-11 md:w-auto bg-blue-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
|
||||
title="Quản lý hệ thống"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
@@ -354,7 +354,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="bg-white p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700 border border-gray-100"
|
||||
className="w-11 h-11 md:w-auto bg-white p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold text-gray-700 border border-gray-100 shrink-0"
|
||||
title="Đăng xuất"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
@@ -383,7 +383,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
||||
<RecenterMap position={userPos} />
|
||||
|
||||
<MarkerClusterGroup chunkedLoading>
|
||||
<MarkerClusterGroup key={`cluster-${filteredTours.length}`} chunkedLoading>
|
||||
{filteredTours.map((tour) => {
|
||||
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||
@@ -479,6 +479,21 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={(tour) => {
|
||||
// Tự động tạo ghi chú mới cho hành trình vừa tạo
|
||||
const savedNotes = localStorage.getItem('my_journey_notes');
|
||||
let notes = [];
|
||||
try {
|
||||
notes = savedNotes ? JSON.parse(savedNotes) : [];
|
||||
} catch (e) { notes = []; }
|
||||
|
||||
const newTourNote = {
|
||||
id: Date.now().toString(),
|
||||
title: `Ghi chú của hành trình: ${tour.title}`,
|
||||
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${tour.title}</strong> của bạn tại đây...</p>`,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
localStorage.setItem('my_journey_notes', JSON.stringify([newTourNote, ...notes]));
|
||||
|
||||
fetchTour(tour.id);
|
||||
onViewTour(tour.id);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { ChevronLeft, FileText, Plus, Search, Trash2, Loader2, Save, Calendar } from 'lucide-react';
|
||||
import ReactQuill, { Quill } from 'react-quill-new'; // Nếu react-quill gặp lỗi với React 18, hãy dùng react-quill-new
|
||||
import 'react-quill-new/dist/quill.snow.css';
|
||||
|
||||
// Cấu hình Lucide Icons cho Quill
|
||||
const Icons = Quill.import('ui/icons');
|
||||
Icons['bold'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>';
|
||||
Icons['italic'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>';
|
||||
Icons['underline'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>';
|
||||
Icons['strike'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" y1="12" x2="20" y2="12"/></svg>';
|
||||
Icons['link'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
||||
Icons['image'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
|
||||
Icons['clean'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.9-9.9c1-1 2.5-1 3.4 0l4.4 4.4c1 1 1 2.5 0 3.4L11 21Z"/><path d="m22 21-5.9-5.9"/><path d="M16 11l-5 5"/></svg>';
|
||||
Icons['table'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/></svg>';
|
||||
Icons['header']['1'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="m17 12 3-2v8"/></svg>';
|
||||
Icons['header']['2'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>';
|
||||
Icons['list']['bullet'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>';
|
||||
Icons['list']['check'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="m9 12 2 2 4-4"/></svg>';
|
||||
Icons['align'][''] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>';
|
||||
Icons['align']['center'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="10" x2="6" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="18" y1="18" x2="6" y2="18"/></svg>';
|
||||
Icons['align']['right'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="10" x2="7" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="7" y2="18"/></svg>';
|
||||
Icons['align']['justify'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>';
|
||||
Icons['indent']['+1'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="11 17 16 12 11 7"/><line x1="18" y1="12" x2="3" y2="12"/></svg>';
|
||||
Icons['indent']['-1'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="8 17 3 12 8 7"/><line x1="21" y1="12" x2="6" y2="12"/></svg>';
|
||||
|
||||
interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const MyNotePage = ({ onBack }: { onBack: () => void }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const quillRef = useRef<ReactQuill>(null);
|
||||
|
||||
// State cho form ghi chú
|
||||
const [noteForm, setNoteForm] = useState({ title: '', content: '' });
|
||||
|
||||
// Khôi phục ghi chú từ localStorage khi load trang
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('my_journey_notes');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (Array.isArray(parsed)) {
|
||||
setNotes(parsed);
|
||||
}
|
||||
} catch (e) { console.error("Lỗi parse notes:", e); }
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Tự động lưu ghi chú vào localStorage khi có thay đổi
|
||||
useEffect(() => {
|
||||
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
|
||||
}, [notes]);
|
||||
|
||||
// Cấu hình các công cụ định dạng cho ReactQuill
|
||||
const quillModules = useMemo(() => ({
|
||||
table: true,
|
||||
toolbar: {
|
||||
container: [
|
||||
[{ 'header': 1 }, { 'header': 2 }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'align': [] }],
|
||||
[{ 'indent': '-1'}, { 'indent': '+1' }],
|
||||
[{ 'list': 'bullet' }, { 'list': 'check' }],
|
||||
['link', 'image', 'table', 'clean'],
|
||||
],
|
||||
handlers: {
|
||||
table: function() {
|
||||
const rows = prompt('Nhập số hàng:', '3');
|
||||
const cols = prompt('Nhập số cột:', '3');
|
||||
if (rows && cols) {
|
||||
const quill = (quillRef.current as any)?.getEditor();
|
||||
if (quill) {
|
||||
quill.getModule('table').insertTable(parseInt(rows), parseInt(cols));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}), []);
|
||||
|
||||
const handleSaveNote = () => {
|
||||
if (!noteForm.title.trim() && !noteForm.content.trim()) return;
|
||||
|
||||
const note: Note = {
|
||||
id: Date.now().toString(),
|
||||
title: noteForm.title || 'Ghi chú không tiêu đề',
|
||||
content: noteForm.content,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setNotes([note, ...notes]);
|
||||
setIsCreating(false);
|
||||
setNoteForm({ title: '', content: '' });
|
||||
};
|
||||
|
||||
const handleDeleteNote = (id: string) => {
|
||||
if (window.confirm("Bạn có chắc chắn muốn xóa ghi chú này?")) {
|
||||
setNotes(prev => prev.filter(n => n.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const filteredNotes = (notes || []).filter(n => {
|
||||
const title = (n.title || '').toLowerCase();
|
||||
const content = (n.content || '').replace(/<[^>]*>/g, '').toLowerCase();
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
||||
return title.includes(query) || content.includes(query);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-4 flex items-center gap-4">
|
||||
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-black text-gray-900">Ghi chú của tôi</h1>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Sổ tay hành trình cá nhân</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 max-w-2xl mx-auto w-full">
|
||||
{!isCreating && (
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tìm kiếm nội dung ghi chú..."
|
||||
className="w-full pl-10 pr-4 py-3 bg-white rounded-2xl border border-gray-100 shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-sm"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="p-3 bg-amber-500 text-white rounded-2xl shadow-lg shadow-amber-200 hover:bg-amber-600 transition-all active:scale-95"
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-4" />
|
||||
<p className="font-bold">Đang tải ghi chú...</p>
|
||||
</div>
|
||||
) : isCreating ? (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tiêu đề ghi chú..."
|
||||
className="w-full px-4 py-3 bg-white rounded-2xl border border-gray-100 shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-lg font-bold"
|
||||
value={noteForm.title}
|
||||
onChange={(e) => setNoteForm({ ...noteForm, title: e.target.value })}
|
||||
/>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden min-h-[500px] flex flex-col">
|
||||
<ReactQuill
|
||||
ref={quillRef}
|
||||
theme="snow"
|
||||
value={noteForm.content}
|
||||
onChange={(content) => setNoteForm({ ...noteForm, content })}
|
||||
modules={quillModules}
|
||||
placeholder="Bắt đầu viết cảm nhận của bạn tại đây..."
|
||||
className="flex-1 flex flex-col [&_.ql-container]:flex-1 [&_.ql-container]:flex [&_.ql-container]:flex-col [&_.ql-editor]:flex-1 [&_.ql-editor]:text-base [&_.ql-toolbar]:flex [&_.ql-toolbar]:flex-wrap sm:[&_.ql-toolbar]:flex-nowrap [&_.ql-toolbar]:border-0 [&_.ql-toolbar]:border-b [&_.ql-toolbar]:border-gray-50 [&_.ql-editor_table]:border-collapse [&_.ql-editor_table]:w-full [&_.ql-editor_td]:border [&_.ql-editor_td]:border-gray-200 [&_.ql-editor_td]:p-2 [&_.ql-editor_.ql-align-center]:text-center [&_.ql-editor_.ql-align-right]:text-right [&_.ql-editor_.ql-align-justify]:text-justify [&_.ql-picker-options]:!z-[50] [&_.ql-picker-options]:!shadow-xl [&_.ql-picker-options]:!rounded-xl [&_.ql-picker-options]:!border-gray-100 [&_.ql-stroke]:!stroke-gray-500 [&_.ql-fill]:!fill-gray-500 [&_button:hover_.ql-stroke]:!stroke-amber-500 [&_button:hover_.ql-fill]:!stroke-amber-500 [&_button.ql-active_.ql-stroke]:!stroke-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSaveNote}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-amber-500 hover:bg-amber-600 text-white py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-amber-200"
|
||||
>
|
||||
<Save className="w-5 h-5" /> Lưu ghi chú
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsCreating(false); setNoteForm({ title: '', content: '' }); }}
|
||||
className="px-6 py-4 bg-gray-100 hover:bg-gray-200 text-gray-500 rounded-2xl font-black uppercase tracking-widest text-xs transition-all"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredNotes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{filteredNotes.map((note) => (
|
||||
<div key={note.id} className="bg-white p-5 rounded-3xl border border-gray-100 shadow-sm hover:shadow-md transition-all group">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900">{note.title}</h4>
|
||||
<div className="flex items-center gap-2 text-[10px] text-gray-400 font-bold uppercase mt-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(note.createdAt).toLocaleDateString('vi-VN')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteNote(note.id)}
|
||||
className="p-2 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-gray-600 line-clamp-3 prose prose-sm max-w-none ql-editor !p-0 [&_table]:border-collapse [&_table]:w-full [&_td]:border [&_td]:border-gray-200 [&_td]:p-2 [&_.ql-align-center]:text-center [&_.ql-align-right]:text-right [&_.ql-align-justify]:text-justify"
|
||||
dangerouslySetInnerHTML={{ __html: note.content }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-24 bg-white rounded-[40px] border-2 border-dashed border-gray-100 shadow-inner">
|
||||
<div className="w-20 h-20 bg-amber-50 rounded-3xl flex items-center justify-center mx-auto mb-6 text-amber-500">
|
||||
<FileText className="w-10 h-10" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">{searchQuery ? 'Không tìm thấy ghi chú' : 'Chưa có ghi chú nào'}</h3>
|
||||
<p className="text-sm text-gray-400 max-w-xs mx-auto mt-2">
|
||||
{searchQuery ? 'Hãy thử tìm kiếm với từ khóa khác.' : 'Hãy lưu lại những cảm nhận, lịch trình riêng hoặc các lưu ý quan trọng cho hành trình của bạn.'}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="mt-8 inline-flex items-center gap-2 bg-amber-500 hover:bg-amber-600 text-white px-8 py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-amber-200 active:scale-95"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> Tạo ghi chú mới
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+177
-142
@@ -1,12 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin } from 'lucide-react';
|
||||
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin, ShieldCheck } from 'lucide-react';
|
||||
|
||||
interface SignupPageProps {
|
||||
onBack: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
@@ -15,39 +15,58 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
||||
phone: '',
|
||||
address: ''
|
||||
});
|
||||
const [step, setStep] = useState<'form' | 'otp'>('form');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
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(`/api/v1/auth/signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
name: formData.name,
|
||||
phone: formData.phone || undefined,
|
||||
address: formData.address || undefined
|
||||
}),
|
||||
});
|
||||
if (step === 'form') {
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Mật khẩu xác nhận không khớp');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const response = await fetch(`/api/v1/auth/signup/request`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
name: formData.name,
|
||||
phone: formData.phone || undefined,
|
||||
address: formData.address || undefined
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Yêu cầu đăng ký thất bại');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Đăng ký thất bại');
|
||||
setStep('otp');
|
||||
} else {
|
||||
// Xác thực mã OTP bước hoàn tất
|
||||
const response = await fetch(`/api/v1/auth/signup/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
otp: otp
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Mã OTP không chính xác');
|
||||
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -56,139 +75,155 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
||||
};
|
||||
|
||||
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 bè.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 bg-white p-10 rounded-3xl shadow-2xl">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="absolute top-6 left-6 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* 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">
|
||||
{step === 'form' ? 'Tạo tài khoản mới' : 'Xác thực tài khoản'}
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-2 font-medium">
|
||||
{step === 'form'
|
||||
? 'Khám phá các tính năng lập kế hoạch chuyên nghiệp.'
|
||||
: `Vui lòng nhập mã OTP đã được gửi tới ${formData.email}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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}>
|
||||
{step === 'form' ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Họ và 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="Nhập họ và tên của bạn"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('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>
|
||||
|
||||
<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ọ và tên</label>
|
||||
<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="Nhập email của bạn"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('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-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>
|
||||
<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
|
||||
required
|
||||
type="password"
|
||||
placeholder="Nhập mật khẩu"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', 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">Xác nhận mật khẩu</label>
|
||||
<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
|
||||
required
|
||||
type="password"
|
||||
placeholder="Xác nhận mật khẩu"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleChange('confirmPassword', 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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
|
||||
<div className="relative group">
|
||||
<Phone 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="tel"
|
||||
placeholder="Nhập số điện thoại"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', 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">Địa chỉ</label>
|
||||
<div className="relative group">
|
||||
<MapPin 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="text"
|
||||
placeholder="Nhập địa chỉ"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', 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>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Mã xác thực OTP</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" />
|
||||
<ShieldCheck 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"
|
||||
maxLength={6}
|
||||
placeholder="Nhập 6 số mã OTP"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl text-center font-bold tracking-[0.5em] text-lg 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">Số điện thoại</label>
|
||||
<div className="relative group">
|
||||
<Phone 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="tel"
|
||||
placeholder="0912 345 678"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: 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">Địa chỉ</label>
|
||||
<div className="relative group">
|
||||
<MapPin 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="text"
|
||||
placeholder="Quận 1, TP.HCM"
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData({ ...formData, address: 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>
|
||||
|
||||
<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>
|
||||
<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ý...' : step === 'form' ? 'Tạo tài khoản' : 'Xác nhận kích hoạt'}
|
||||
{!isLoading && <ArrowRight className="w-5 h-5" />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignupPage;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+458
-2
@@ -13,7 +13,8 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8"
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"react-quill": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
@@ -24,6 +25,7 @@
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^3.1.3",
|
||||
"@nestjs/common": "^11.1.27",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.27",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
@@ -36,6 +38,7 @@
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"nodemailer": "^9.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.12.0",
|
||||
@@ -207,6 +210,8 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
@@ -222,6 +227,62 @@
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/fast-diff": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"frontend/node_modules/parchment": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"frontend/node_modules/quill": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"parchment": "^3.0.0",
|
||||
"quill-delta": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.2.3"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/quill-delta": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-diff": "^1.3.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.isequal": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/react-quill-new": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
|
||||
"integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21",
|
||||
"quill": "~2.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"quill-delta": "^5.1.0",
|
||||
"react": "^16 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
@@ -2015,6 +2076,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/config": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz",
|
||||
"integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dotenv": "17.4.1",
|
||||
"dotenv-expand": "12.0.3",
|
||||
"lodash": "4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"rxjs": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/config/node_modules/dotenv": {
|
||||
"version": "17.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
|
||||
"integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/config/node_modules/dotenv-expand": {
|
||||
"version": "12.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
|
||||
"integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/config/node_modules/dotenv-expand/node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/core": {
|
||||
"version": "11.1.27",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz",
|
||||
@@ -3035,6 +3150,15 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/quill": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
|
||||
"integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parchment": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
@@ -3777,6 +3901,24 @@
|
||||
"@redis/time-series": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -4269,6 +4411,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deep-equal": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
|
||||
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arguments": "^1.1.1",
|
||||
"is-date-object": "^1.0.5",
|
||||
"is-regex": "^1.1.4",
|
||||
"object-is": "^1.1.5",
|
||||
"object-keys": "^1.1.1",
|
||||
"regexp.prototype.flags": "^1.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -4292,6 +4454,40 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-properties": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.0",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -4762,6 +4958,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -4769,6 +4971,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
|
||||
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -4983,6 +5191,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/functions-have-names": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
|
||||
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/generic-pool": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
||||
@@ -5132,6 +5349,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -5144,6 +5373,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hashery": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
|
||||
@@ -5282,6 +5526,22 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
@@ -5289,6 +5549,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-date-object": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
|
||||
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
@@ -5315,6 +5591,24 @@
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"gopd": "^1.2.0",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-unicode-supported": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
||||
@@ -5843,7 +6137,12 @@
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
@@ -5864,6 +6163,13 @@
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
@@ -6221,6 +6527,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -6251,6 +6566,31 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-is": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.7",
|
||||
"define-properties": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -6318,6 +6658,12 @@
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
|
||||
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -6695,6 +7041,49 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/quill": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
|
||||
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clone": "^2.1.1",
|
||||
"deep-equal": "^1.0.1",
|
||||
"eventemitter3": "^2.0.3",
|
||||
"extend": "^3.0.2",
|
||||
"parchment": "^1.1.4",
|
||||
"quill-delta": "^3.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-delta": {
|
||||
"version": "3.6.3",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
|
||||
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deep-equal": "^1.0.1",
|
||||
"extend": "^3.0.2",
|
||||
"fast-diff": "1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/quill/node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/quill/node_modules/eventemitter3": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
|
||||
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
@@ -6783,6 +7172,21 @@
|
||||
"react-leaflet": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-quill": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
|
||||
"integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/quill": "^1.3.10",
|
||||
"lodash": "^4.17.4",
|
||||
"quill": "^1.3.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16 || ^17 || ^18",
|
||||
"react-dom": "^16 || ^17 || ^18"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@@ -6913,6 +7317,26 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"define-properties": "^1.2.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"set-function-name": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -7194,6 +7618,38 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"gopd": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/set-function-name": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
|
||||
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"functions-have-names": "^1.2.3",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
|
||||
+2
-1
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8"
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"react-quill": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user