26 Commits

Author SHA1 Message Date
3dtours 6026c2227a fix: UI của trang MyPhotoPage lỗi 2026-06-16 21:25:08 +07:00
3dtours 1eed9b00de feat: xem ảnh theo cấu trúc của lightroom 2026-06-16 20:38:40 +07:00
3dtours 1709638bfd optimize: xác nhận redis đã nén dữ liệu 2026-06-16 20:22:53 +07:00
3dtours 37b5d14d7e optimize: sử dụng redis để cache cho các response từ server 2026-06-16 20:17:17 +07:00
3dtours 2fabdb79df fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user 2026-06-16 18:54:55 +07:00
3dtours 00554224a1 fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user 2026-06-16 18:54:24 +07:00
3dtours 88b2182789 feat: thêm chức năng upload ảnh 2026-06-16 17:14:22 +07:00
3dtours 989d60643b feat: thêm nút tìm kiếm địa điểm cho phần tạo tour, phần thêm địa điểm 2026-06-16 15:45:26 +07:00
3dtours 1772ced959 feat: nút lọc filter tags dropdown 2026-06-16 15:35:45 +07:00
3dtours c005009da2 feat: Chuyển khám phá địa điểm thành box tìm kiếm địa điểm trên bản đồ 2026-06-16 15:28:10 +07:00
3dtours bfd18e05dd feat: tạo hook useNotification.tsx để dùng chung toàn hệ thống 2026-06-16 12:41:00 +07:00
3dtours 29d39ae7b0 feat: tạo hook useConfirm.tsx để dùng chung toàn hệ thống 2026-06-16 12:29:09 +07:00
3dtours 047170d4be feat: cho phép người dùng và người quản lí xóa bình luận 2026-06-16 12:21:44 +07:00
3dtours fc96ee9eb8 fix: tìm kiếm địa điểm với cách gõ tên trên bản đồ 2026-06-16 12:15:01 +07:00
3dtours 0a584a13c7 feat: tìm kiếm địa điểm trên bản đồ 2026-06-16 11:37:21 +07:00
3dtours f78c72ad60 fix: hiển thị mô tả và tên của hành trình có thêm tags 2026-06-16 11:21:43 +07:00
3dtours 8cca4a83ca fix: tags không có trong csdl gây nên lỗi crash backend 2026-06-16 11:15:39 +07:00
3dtours bc49e081c6 feat: cho phép lấy thời gian và địa điểm hiện tại để gán cho điểm 2026-06-16 10:55:18 +07:00
3dtours 9e26aabce6 Sửa lỗi hiển thị chi phí ở điểm của chặng 2026-06-16 10:36:38 +07:00
3dtours 3e1a5db1a6 Sửa lỗi hiển thị bảng liệt kê chi phí 2026-06-16 10:28:33 +07:00
3dtours baa83aad8a Sửa lỗi người nhận được line xem được nội dung Tour nhưng không xem được bình luận 2026-06-16 09:56:59 +07:00
3dtours 302a82e887 Sửa lỗi người nhận được liên kết chia sẻ không xem được nội dung Tour 2026-06-16 09:54:32 +07:00
3dtours 63da413b3c Sửa lỗi OWNER, MANAGER không thể chia sẻ link 2026-06-16 09:33:15 +07:00
3dtours 02c493f7e0 Thêm tính năng click chuột phải vào bong bóng để lấy link chia sẻ 2026-06-16 08:46:45 +07:00
3dtours b939fc959d Thêm tính năng hiển thị tooltip trên bong bóng Tour 2026-06-16 08:35:48 +07:00
3dtours 5464d90948 Thêm tính năng bình luận ở mỗi điểm của chặng 2026-06-16 08:27:54 +07:00
36 changed files with 5530 additions and 584 deletions
+10
View File
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Cache } from 'cache-manager';
import { HttpAdapterHost } from '@nestjs/core';
export declare class CompressCacheInterceptor implements NestInterceptor {
private cacheManager;
private readonly httpAdapterHost;
constructor(cacheManager: Cache, httpAdapterHost: HttpAdapterHost);
intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>>;
}
+123
View File
@@ -0,0 +1,123 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CompressCacheInterceptor = void 0;
const common_1 = require("@nestjs/common");
const rxjs_1 = require("rxjs");
const operators_1 = require("rxjs/operators");
const cache_manager_1 = require("@nestjs/cache-manager");
const zlib = __importStar(require("zlib"));
const util_1 = require("util");
const core_1 = require("@nestjs/core");
const gzip = (0, util_1.promisify)(zlib.gzip);
const gunzip = (0, util_1.promisify)(zlib.gunzip);
const COMPRESSION_THRESHOLD = 100;
let CompressCacheInterceptor = class CompressCacheInterceptor {
constructor(cacheManager, httpAdapterHost) {
this.cacheManager = cacheManager;
this.httpAdapterHost = httpAdapterHost;
}
async intercept(context, next) {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const request = context.getArgByIndex(0);
const response = context.getArgByIndex(1);
if (httpAdapter.getRequestMethod(request) !== 'GET') {
return next.handle();
}
const cacheKey = httpAdapter.getRequestUrl(request);
let cachedData = await this.cacheManager.get(cacheKey);
if (cachedData) {
try {
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
const decompressed = await gunzip(cachedData);
const jsonString = decompressed.toString('utf8');
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
return (0, rxjs_1.of)(JSON.parse(jsonString));
}
else {
const jsonString = cachedData.toString();
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
return (0, rxjs_1.of)(JSON.parse(jsonString));
}
}
catch (e) {
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
await this.cacheManager.del(cacheKey);
}
}
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
return next.handle().pipe((0, operators_1.tap)(async (data) => {
if (!data)
return;
const jsonString = JSON.stringify(data);
const ttl = 60000;
if (jsonString.length > COMPRESSION_THRESHOLD) {
try {
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
await this.cacheManager.set(cacheKey, compressed, ttl);
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
}
catch (e) {
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
}
else {
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
}));
}
};
exports.CompressCacheInterceptor = CompressCacheInterceptor;
exports.CompressCacheInterceptor = CompressCacheInterceptor = __decorate([
(0, common_1.Injectable)(),
__param(0, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [Object, core_1.HttpAdapterHost])
], CompressCacheInterceptor);
//# sourceMappingURL=compress-cache.interceptor.js.map
@@ -0,0 +1 @@
{"version":3,"file":"compress-cache.interceptor.js","sourceRoot":"","sources":["../../../src/common/compress-cache.interceptor.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAAoG;AACpG,+BAAsC;AACtC,8CAAqC;AACrC,yDAAsD;AAEtD,2CAA6B;AAC7B,+BAAiC;AACjC,uCAA+C;AAG/C,MAAM,IAAI,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAClC,MAAM,MAAM,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAItC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAG3B,IAAM,wBAAwB,GAA9B,MAAM,wBAAwB;IACnC,YACiC,YAAmB,EACjC,eAAgC;QADlB,iBAAY,GAAZ,YAAY,CAAO;QACjC,oBAAe,GAAf,eAAe,CAAiB;IAChD,CAAC;IAEJ,KAAK,CAAC,SAAS,CAAC,OAAyB,EAAE,IAAiB;QAC1D,MAAM,WAAW,GAAG,IAAI,CAAC,eAAe,CAAC,WAAW,CAAC;QACrD,MAAM,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QACzC,MAAM,QAAQ,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QAG1C,IAAI,WAAW,CAAC,gBAAgB,CAAC,OAAO,CAAC,KAAK,KAAK,EAAE,CAAC;YACpD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC;QACvB,CAAC;QAED,MAAM,QAAQ,GAAG,WAAW,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;QACpD,IAAI,UAAU,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAS,QAAQ,CAAC,CAAC;QAE/D,IAAI,UAAU,EAAE,CAAC;YACf,IAAI,CAAC;gBAEH,IAAI,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;oBAC7G,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,CAAC;oBAC9C,MAAM,UAAU,GAAG,YAAY,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;oBAEjD,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,kBAAkB,CAAC,CAAC;oBAC/D,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;qBAAM,CAAC;oBAEN,MAAM,UAAU,GAAG,UAAU,CAAC,QAAQ,EAAE,CAAC;oBACzC,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,oBAAoB,CAAC,CAAC;oBACjE,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;YACH,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,OAAO,CAAC,KAAK,CAAC,8CAA8C,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;gBAE5E,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YACxC,CAAC;QACH,CAAC;QAID,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;QAEnD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CACvB,IAAA,eAAG,EAAC,KAAK,EAAE,IAAI,EAAE,EAAE;YACjB,IAAI,CAAC,IAAI;gBAAE,OAAO;YAElB,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YACxC,MAAM,GAAG,GAAG,KAAK,CAAC;YAElB,IAAI,UAAU,CAAC,MAAM,GAAG,qBAAqB,EAAE,CAAC;gBAC9C,IAAI,CAAC;oBACH,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC;oBAC/D,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;oBACvD,OAAO,CAAC,GAAG,CAAC,kCAAkC,QAAQ,KAAK,UAAU,CAAC,MAAM,OAAO,UAAU,CAAC,MAAM,SAAS,CAAC,CAAC;gBAEjH,CAAC;gBAAC,OAAO,CAAC,EAAE,CAAC;oBACX,OAAO,CAAC,KAAK,CAAC,mCAAmC,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;oBAEjE,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;gBACzD,CAAC;YACH,CAAC;iBAAM,CAAC;gBAEN,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;YACzD,CAAC;QACH,CAAC,CAAC,CACH,CAAC;IACJ,CAAC;CACF,CAAA;AAxEY,4DAAwB;mCAAxB,wBAAwB;IADpC,IAAA,mBAAU,GAAE;IAGR,WAAA,IAAA,eAAM,EAAC,6BAAa,CAAC,CAAA;6CACY,sBAAe;GAHxC,wBAAwB,CAwEpC"}
+22
View File
@@ -1 +1,23 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { OnGatewayConnection } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { PrismaService } from '../prisma/prisma.service';
import { ParticipantRole } from '@prisma/client';
import { Reflector } from '@nestjs/core';
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { Cache } from 'cache-manager';
export declare const ROLES_KEY = "roles";
export declare const Roles: (...roles: ParticipantRole[]) => import("@nestjs/common").CustomDecorator<string>;
export declare class TourRoleGuard implements CanActivate {
private reflector;
private prisma;
private cacheManager;
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
canActivate(context: ExecutionContext): Promise<boolean>;
}
export declare class CommentGateway implements OnGatewayConnection {
server: Server;
handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void;
notifyNewComment(tourId: string, data: any): void;
}
+510 -24
View File
@@ -44,21 +44,45 @@ var __metadata = (this && this.__metadata) || function (k, v) {
var __param = (this && this.__param) || function (paramIndex, decorator) { var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); } return function (target, key) { decorator(target, key, paramIndex); }
}; };
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.CommentGateway = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = void 0;
const dotenv = __importStar(require("dotenv")); const dotenv = __importStar(require("dotenv"));
const path = __importStar(require("path")); const path = __importStar(require("path"));
const envPath = path.resolve(process.cwd(), '..', '.env'); const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath }); dotenv.config({ path: envPath });
require("reflect-metadata"); require("reflect-metadata");
const fs = __importStar(require("fs"));
const zlib = __importStar(require("zlib"));
const util_1 = require("util");
const sharp_1 = __importDefault(require("sharp"));
const core_1 = require("@nestjs/core"); const core_1 = require("@nestjs/core");
const common_1 = require("@nestjs/common"); const common_1 = require("@nestjs/common");
const platform_express_1 = require("@nestjs/platform-express");
const websockets_1 = require("@nestjs/websockets");
const socket_io_1 = require("socket.io");
const prisma_service_1 = require("../prisma/prisma.service"); const prisma_service_1 = require("../prisma/prisma.service");
const client_1 = require("@prisma/client");
const bcrypt = __importStar(require("bcrypt")); const bcrypt = __importStar(require("bcrypt"));
const admin_guard_1 = require("./auth/admin.guard"); const admin_guard_1 = require("./auth/admin.guard");
const jwt_1 = require("@nestjs/jwt"); const jwt_1 = require("@nestjs/jwt");
const jwt_auth_guard_1 = require("./auth/jwt-auth.guard"); const jwt_auth_guard_1 = require("./auth/jwt-auth.guard");
const jwt_strategy_1 = require("./auth/jwt.strategy"); const jwt_strategy_1 = require("./auth/jwt.strategy");
const rbac_middleware_1 = require("./common/rbac.middleware"); const core_2 = require("@nestjs/core");
const common_2 = require("@nestjs/common");
const cache_manager_1 = require("@nestjs/cache-manager");
const cache_manager_redis_yet_1 = require("cache-manager-redis-yet");
const compress_cache_interceptor_1 = require("./common/compress-cache.interceptor");
const gzip = (0, util_1.promisify)(zlib.gzip);
const gunzip = (0, util_1.promisify)(zlib.gunzip);
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads');
const CACHE_TTL = {
DEFAULT: 600000,
RESOURCE_TO_TOUR: 3600000,
USER_ROLE: 300000,
};
async function bootstrap() { async function bootstrap() {
if (!process.env.DATABASE_URL) { 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); 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);
@@ -69,9 +93,112 @@ async function bootstrap() {
const app = await core_1.NestFactory.create(AppModule); const app = await core_1.NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');
app.enableCors(); app.enableCors();
if (!fs.existsSync(UPLOAD_ROOT)) {
fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
}
app.useStaticAssets(UPLOAD_ROOT, {
prefix: '/uploads/',
});
await app.listen(3001); await app.listen(3001);
console.log(`🚀 Server is running on: http://localhost:3001`); console.log(`🚀 Server is running on: http://localhost:3001`);
} }
exports.ROLES_KEY = 'roles';
const Roles = (...roles) => (0, common_2.SetMetadata)(exports.ROLES_KEY, roles);
exports.Roles = Roles;
let TourRoleGuard = class TourRoleGuard {
constructor(reflector, prisma, cacheManager) {
this.reflector = reflector;
this.prisma = prisma;
this.cacheManager = cacheManager;
}
async canActivate(context) {
const requiredRoles = this.reflector.getAllAndOverride(exports.ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
const defaultRoles = [client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER];
const rolesToCheck = requiredRoles && requiredRoles.length > 0 ? requiredRoles : defaultRoles;
const request = context.switchToHttp().getRequest();
const user = request.user;
let tourId = request.params.tourId;
const resourceId = request.params.id || request.params.legId || request.params.locationId;
if (!tourId && resourceId) {
const resCacheKey = `res-to-tour:${resourceId}`;
const compressedData = await this.cacheManager.get(resCacheKey);
if (compressedData) {
try {
const decompressed = await gunzip(compressedData);
tourId = decompressed.toString();
}
catch (e) {
console.error('Lỗi giải nén cache:', e);
}
}
else {
const isTour = await this.prisma.tour.findUnique({ where: { id: resourceId }, select: { id: true } });
if (isTour) {
tourId = resourceId;
}
else {
const leg = await this.prisma.leg.findUnique({ where: { id: resourceId }, select: { tourId: true } });
if (leg) {
tourId = leg.tourId;
}
else {
const loc = await this.prisma.location.findUnique({
where: { id: resourceId },
include: { leg: { select: { tourId: true } } }
});
if (loc) {
tourId = loc.leg.tourId;
}
else {
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true } });
if (photo)
tourId = photo.tourId;
}
}
}
if (tourId) {
try {
const compressed = await gzip(Buffer.from(tourId));
await this.cacheManager.set(resCacheKey, compressed, CACHE_TTL.RESOURCE_TO_TOUR);
}
catch (e) {
await this.cacheManager.set(resCacheKey, tourId, CACHE_TTL.RESOURCE_TO_TOUR);
}
}
}
}
if (!user || !tourId) {
if (!resourceId && !request.params.tourId)
return true;
return false;
}
const roleCacheKey = `user-role:${user.id}:${tourId}`;
let role = await this.cacheManager.get(roleCacheKey);
if (!role) {
const participation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: user.id } },
});
if (!participation)
return false;
role = participation.role;
await this.cacheManager.set(roleCacheKey, role, CACHE_TTL.USER_ROLE);
}
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.');
}
return true;
}
};
exports.TourRoleGuard = TourRoleGuard;
exports.TourRoleGuard = TourRoleGuard = __decorate([
(0, common_1.Injectable)(),
__param(2, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [core_2.Reflector,
prisma_service_1.PrismaService, Object])
], TourRoleGuard);
let AppController = class AppController { let AppController = class AppController {
getHello() { getHello() {
return 'Travel Planning API is running!'; return 'Travel Planning API is running!';
@@ -154,17 +281,73 @@ AuthController = __decorate([
(0, common_1.Controller)('auth'), (0, common_1.Controller)('auth'),
__metadata("design:paramtypes", [prisma_service_1.PrismaService, jwt_1.JwtService]) __metadata("design:paramtypes", [prisma_service_1.PrismaService, jwt_1.JwtService])
], AuthController); ], AuthController);
let TourController = class TourController { let PublicTourController = class PublicTourController {
constructor(prisma) { constructor(prisma) {
this.prisma = prisma; this.prisma = prisma;
} }
async getPublicTourDetails(id) {
console.log(`[PublicTour] Đang truy vấn chi tiết Tour ID: ${id}`);
const tour = await this.prisma.tour.findUnique({
where: { id },
include: {
participants: {
include: {
user: {
select: { id: true, name: true, email: true }
}
}
},
photos: true,
legs: {
orderBy: { sequence: 'asc' },
include: {
expenses: {
include: {
location: { select: { name: true, plannedStart: true } },
paidBy: { select: { name: true } }
}
},
locations: {
orderBy: { plannedStart: 'asc' },
include: { _count: { select: { comments: true } } }
},
},
},
},
});
if (!tour) {
console.warn(`[PublicTour] Không tìm thấy Tour với ID: ${id} trong Database`);
throw new common_1.NotFoundException(`Không tìm thấy Tour`);
}
return tour;
}
};
__decorate([
(0, common_1.UseInterceptors)(compress_cache_interceptor_1.CompressCacheInterceptor),
(0, common_1.Get)(':id/public'),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String]),
__metadata("design:returntype", Promise)
], PublicTourController.prototype, "getPublicTourDetails", null);
PublicTourController = __decorate([
(0, common_1.Controller)('tours'),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], PublicTourController);
let TourController = class TourController {
constructor(prisma, cacheManager) {
this.prisma = prisma;
this.cacheManager = cacheManager;
}
async createTour(body, req) { async createTour(body, req) {
const { title, startDate, endDate, adultCount, childCount, childDiscount } = body; const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
return this.prisma.tour.create({ return this.prisma.tour.create({
data: { data: {
title, title,
description,
startDate: startDate ? new Date(startDate) : null, startDate: startDate ? new Date(startDate) : null,
endDate: endDate ? new Date(endDate) : null, endDate: endDate ? new Date(endDate) : null,
tags: tags || [],
adultCount: adultCount || 1, adultCount: adultCount || 1,
childCount: childCount || 0, childCount: childCount || 0,
childDiscount: childDiscount || 0, childDiscount: childDiscount || 0,
@@ -332,6 +515,7 @@ let TourController = class TourController {
title: body.title, title: body.title,
description: body.description, description: body.description,
startDate: body.startDate ? new Date(body.startDate) : undefined, startDate: body.startDate ? new Date(body.startDate) : undefined,
tags: body.tags,
endDate: body.endDate ? new Date(body.endDate) : undefined, endDate: body.endDate ? new Date(body.endDate) : undefined,
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined, adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined, childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
@@ -340,6 +524,43 @@ let TourController = class TourController {
}); });
} }
async deleteTour(id) { async deleteTour(id) {
const tour = await this.prisma.tour.findUnique({
where: { id },
include: {
participants: true,
photos: true,
legs: {
include: { locations: true }
}
}
});
if (!tour)
throw new common_1.NotFoundException('Không tìm thấy tour');
for (const participant of tour.participants) {
await this.cacheManager.del(`user-role:${participant.userId}:${id}`);
}
await this.cacheManager.del(`res-to-tour:${id}`);
for (const leg of tour.legs) {
await this.cacheManager.del(`res-to-tour:${leg.id}`);
for (const loc of leg.locations) {
await this.cacheManager.del(`res-to-tour:${loc.id}`);
}
}
for (const photo of tour.photos) {
await this.cacheManager.del(`res-to-tour:${photo.id}`);
}
for (const photo of tour.photos) {
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
fs.unlinkSync(displayFilePath);
}
}
}
await this.prisma.photo.updateMany({
where: { tourId: id },
data: { imageUrl: null }
});
await this.prisma.tour.delete({ await this.prisma.tour.delete({
where: { id }, where: { id },
}); });
@@ -354,11 +575,18 @@ let TourController = class TourController {
}, },
take: 20, take: 20,
include: { include: {
participants: {
where: { userId: req.user.id },
select: { role: true }
},
photos: { take: 1 }, photos: { take: 1 },
legs: { legs: {
orderBy: { sequence: 'asc' }, orderBy: { sequence: 'asc' },
include: { include: {
locations: { orderBy: { plannedStart: 'asc' } } locations: {
orderBy: { plannedStart: 'asc' },
include: { _count: { select: { comments: true } } }
}
} }
} }
} }
@@ -379,7 +607,16 @@ let TourController = class TourController {
legs: { legs: {
orderBy: { sequence: 'asc' }, orderBy: { sequence: 'asc' },
include: { include: {
locations: { orderBy: { plannedStart: 'asc' } }, expenses: {
include: {
location: { select: { name: true, plannedStart: true } },
paidBy: { select: { name: true } }
}
},
locations: {
orderBy: { plannedStart: 'asc' },
include: { _count: { select: { comments: true } } }
},
}, },
}, },
}, },
@@ -391,6 +628,7 @@ let TourController = class TourController {
async addMember(tourId, body, req) { async addMember(tourId, body, req) {
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY']; const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'];
const role = validRoles.includes(body.role) ? body.role : 'MEMBER'; const role = validRoles.includes(body.role) ? body.role : 'MEMBER';
await this.cacheManager.del(`user-role:${body.userId}:${tourId}`);
const participation = await this.prisma.tourParticipant.findUnique({ const participation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: body.userId } }, where: { tourId_userId: { tourId, userId: body.userId } },
}); });
@@ -491,6 +729,7 @@ let TourController = class TourController {
if (joinRequest.status !== 'PENDING') { if (joinRequest.status !== 'PENDING') {
throw new common_1.BadRequestException('Yêu cầu này đã được xử lý trước đó.'); throw new common_1.BadRequestException('Yêu cầu này đã được xử lý trước đó.');
} }
await this.cacheManager.del(`user-role:${joinRequest.userId}:${tourId}`);
const existing = await this.prisma.tourParticipant.findUnique({ const existing = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: joinRequest.userId } }, where: { tourId_userId: { tourId, userId: joinRequest.userId } },
}); });
@@ -549,11 +788,48 @@ let TourController = class TourController {
if (!participation) { if (!participation) {
throw new common_1.NotFoundException('Thành viên này không có trong tour'); throw new common_1.NotFoundException('Thành viên này không có trong tour');
} }
await this.cacheManager.del(`user-role:${userId}:${tourId}`);
await this.prisma.tourParticipant.delete({ await this.prisma.tourParticipant.delete({
where: { tourId_userId: { tourId, userId } }, where: { tourId_userId: { tourId, userId } },
}); });
return { message: 'Đã xóa thành viên khỏi tour' }; return { message: 'Đã xóa thành viên khỏi tour' };
} }
async uploadPhotos(tourId, files, req) {
if (!files || files.length === 0) {
throw new common_1.BadRequestException('Vui lòng chọn ít nhất một ảnh');
}
const uploaderId = req.user.id;
const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals');
const tourDisplayPath = path.join(UPLOAD_ROOT, 'tours');
if (!fs.existsSync(memberOriginalDir))
fs.mkdirSync(memberOriginalDir, { recursive: true });
if (!fs.existsSync(tourDisplayPath))
fs.mkdirSync(tourDisplayPath, { recursive: true });
return Promise.all(files.map(async (file) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const extension = path.extname(file.originalname).toLowerCase() || '.jpg';
const filename = `${uniqueSuffix}${extension}`;
const originalFilePath = path.join(memberOriginalDir, filename);
const displayFilePath = path.join(tourDisplayPath, filename);
await fs.promises.writeFile(originalFilePath, file.buffer);
await (0, sharp_1.default)(file.buffer)
.resize(2560, 2560, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: 85 })
.toFile(displayFilePath);
return this.prisma.photo.create({
data: {
tourId: tourId,
uploaderId: uploaderId,
imageUrl: `/uploads/tours/${filename}`,
originalUrl: `/uploads/members/${uploaderId}/originals/${filename}`,
privacy: 'TOUR_ONLY',
}
});
}));
}
}; };
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
@@ -565,7 +841,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "createTour", null); ], TourController.prototype, "createTour", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':tourId/locations'), (0, common_1.Post)(':tourId/locations'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()), __param(1, (0, common_1.Body)()),
@@ -575,7 +852,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "addLocation", null); ], TourController.prototype, "addLocation", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':tourId/start-point'), (0, common_1.Post)(':tourId/start-point'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()), __param(1, (0, common_1.Body)()),
@@ -585,7 +863,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "updateTourStartPoint", null); ], TourController.prototype, "updateTourStartPoint", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':tourId/end-point'), (0, common_1.Post)(':tourId/end-point'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()), __param(1, (0, common_1.Body)()),
@@ -595,7 +874,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "updateTourEndPoint", null); ], TourController.prototype, "updateTourEndPoint", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':tourId/legs/batch'), (0, common_1.Post)(':tourId/legs/batch'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()), __param(1, (0, common_1.Body)()),
@@ -604,7 +884,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "initializeLegs", null); ], TourController.prototype, "initializeLegs", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':tourId/legs'), (0, common_1.Post)(':tourId/legs'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()), __param(1, (0, common_1.Body)()),
@@ -613,7 +894,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "addLeg", null); ], TourController.prototype, "addLeg", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Patch)(':id'), (0, common_1.Patch)(':id'),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()), __param(1, (0, common_1.Body)()),
@@ -622,7 +904,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "updateTour", null); ], TourController.prototype, "updateTour", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Delete)(':id'), (0, common_1.Delete)(':id'),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__metadata("design:type", Function), __metadata("design:type", Function),
@@ -630,6 +913,7 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "deleteTour", null); ], TourController.prototype, "deleteTour", null);
__decorate([ __decorate([
(0, common_1.UseInterceptors)(compress_cache_interceptor_1.CompressCacheInterceptor),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
(0, common_1.Get)('explore'), (0, common_1.Get)('explore'),
__param(0, (0, common_1.Req)()), __param(0, (0, common_1.Req)()),
@@ -638,7 +922,9 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "getPublicTours", null); ], TourController.prototype, "getPublicTours", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, common_1.UseInterceptors)(compress_cache_interceptor_1.CompressCacheInterceptor),
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE, client_1.ParticipantRole.VIEWER_ONLY),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Get)(':id'), (0, common_1.Get)(':id'),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__metadata("design:type", Function), __metadata("design:type", Function),
@@ -646,7 +932,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "getTourDetails", null); ], TourController.prototype, "getTourDetails", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':tourId/members'), (0, common_1.Post)(':tourId/members'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()), __param(1, (0, common_1.Body)()),
@@ -656,7 +943,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "addMember", null); ], TourController.prototype, "addMember", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Get)(':tourId/join-requests'), (0, common_1.Get)(':tourId/join-requests'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()), __param(1, (0, common_1.Req)()),
@@ -665,7 +953,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "getJoinRequests", null); ], TourController.prototype, "getJoinRequests", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE, client_1.ParticipantRole.VIEWER_ONLY),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':tourId/join-requests'), (0, common_1.Post)(':tourId/join-requests'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()), __param(1, (0, common_1.Body)()),
@@ -675,7 +964,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "createJoinRequest", null); ], TourController.prototype, "createJoinRequest", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':tourId/join-requests/:requestId/accept'), (0, common_1.Post)(':tourId/join-requests/:requestId/accept'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Param)('requestId')), __param(1, (0, common_1.Param)('requestId')),
@@ -685,7 +975,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "acceptJoinRequest", null); ], TourController.prototype, "acceptJoinRequest", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':tourId/join-requests/:requestId/reject'), (0, common_1.Post)(':tourId/join-requests/:requestId/reject'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Param)('requestId')), __param(1, (0, common_1.Param)('requestId')),
@@ -695,7 +986,8 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "rejectJoinRequest", null); ], TourController.prototype, "rejectJoinRequest", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Delete)(':tourId/members/:userId'), (0, common_1.Delete)(':tourId/members/:userId'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Param)('userId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Param)('userId', common_1.ParseUUIDPipe)),
@@ -703,9 +995,22 @@ __decorate([
__metadata("design:paramtypes", [String, String]), __metadata("design:paramtypes", [String, String]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "removeMember", null); ], TourController.prototype, "removeMember", null);
__decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':tourId/photos'),
(0, common_1.UseInterceptors)((0, platform_express_1.FilesInterceptor)('images', 10)),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.UploadedFiles)()),
__param(2, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Array, Object]),
__metadata("design:returntype", Promise)
], TourController.prototype, "uploadPhotos", null);
TourController = __decorate([ TourController = __decorate([
(0, common_1.Controller)('tours'), (0, common_1.Controller)('tours'),
__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])
], TourController); ], TourController);
let LocationController = class LocationController { let LocationController = class LocationController {
constructor(prisma) { constructor(prisma) {
@@ -821,7 +1126,8 @@ __decorate([
], LegController.prototype, "deleteLeg", null); ], LegController.prototype, "deleteLeg", null);
LegController = __decorate([ LegController = __decorate([
(0, common_1.Controller)('legs'), (0, common_1.Controller)('legs'),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (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]) __metadata("design:paramtypes", [prisma_service_1.PrismaService])
], LegController); ], LegController);
function calculateDistance(lat1, lon1, lat2, lon2) { function calculateDistance(lat1, lon1, lat2, lon2) {
@@ -923,8 +1229,54 @@ __decorate([
], RoutingController.prototype, "optimize", null); ], RoutingController.prototype, "optimize", null);
RoutingController = __decorate([ RoutingController = __decorate([
(0, common_1.Controller)('routing'), (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]) __metadata("design:paramtypes", [prisma_service_1.PrismaService])
], RoutingController); ], RoutingController);
let PhotoController = class PhotoController {
constructor(prisma) {
this.prisma = prisma;
}
async deletePhoto(id, req) {
const photo = await this.prisma.photo.findUnique({
where: { id },
});
if (!photo) {
throw new common_1.NotFoundException('Không tìm thấy ảnh.');
}
if (photo.uploaderId !== req.user.id) {
throw new common_1.ForbiddenException('Bạn không có quyền xóa ảnh này.');
}
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
fs.unlinkSync(displayFilePath);
}
}
if (photo.originalUrl) {
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
if (fs.existsSync(originalFilePath)) {
fs.unlinkSync(originalFilePath);
}
}
await this.prisma.photo.delete({ where: { id } });
return { message: 'Ảnh đã được xóa thành công.' };
}
};
__decorate([
(0, common_1.Delete)(':id'),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object]),
__metadata("design:returntype", Promise)
], PhotoController.prototype, "deletePhoto", null);
PhotoController = __decorate([
(0, common_1.Controller)('photos'),
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], PhotoController);
let UserController = class UserController { let UserController = class UserController {
constructor(prisma) { constructor(prisma) {
this.prisma = prisma; this.prisma = prisma;
@@ -944,6 +1296,15 @@ let UserController = class UserController {
}); });
return users.filter((u) => u.id !== currentUserId); return users.filter((u) => u.id !== currentUserId);
} }
async getMyPhotos(req) {
return this.prisma.photo.findMany({
where: { uploaderId: req.user.id },
include: {
tour: { select: { title: true } }
},
orderBy: { capturedAt: 'desc' }
});
}
async updateUser(id, data) { async updateUser(id, data) {
if (data.password) { if (data.password) {
data.passwordHash = await bcrypt.hash(data.password, 10); data.passwordHash = await bcrypt.hash(data.password, 10);
@@ -964,8 +1325,23 @@ let UserController = class UserController {
if (adminCount <= 1) if (adminCount <= 1)
throw new common_1.BadRequestException('Không thể xóa Quản trị viên cuối cùng'); throw new common_1.BadRequestException('Không thể xóa Quản trị viên cuối cùng');
} }
const memberDir = path.join(UPLOAD_ROOT, 'members', id);
const photos = await this.prisma.photo.findMany({
where: { uploaderId: id }
});
for (const photo of photos) {
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath))
fs.unlinkSync(displayFilePath);
}
}
await this.prisma.photo.deleteMany({ where: { uploaderId: id } });
await this.prisma.tourParticipant.deleteMany({ where: { userId: id } }); await this.prisma.tourParticipant.deleteMany({ where: { userId: id } });
await this.prisma.user.delete({ where: { id } }); await this.prisma.user.delete({ where: { id } });
if (fs.existsSync(memberDir)) {
fs.rmSync(memberDir, { recursive: true, force: true });
}
return { message: 'Đã xóa người dùng' }; return { message: 'Đã xóa người dùng' };
} }
async toggleBlock(id) { async toggleBlock(id) {
@@ -989,6 +1365,15 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], UserController.prototype, "getAllUsers", null); ], UserController.prototype, "getAllUsers", null);
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
(0, common_1.Get)('me/photos'),
__param(0, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise)
], UserController.prototype, "getMyPhotos", null);
__decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.Patch)(':id'), (0, common_1.Patch)(':id'),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()), __param(1, (0, common_1.Body)()),
@@ -997,6 +1382,7 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], UserController.prototype, "updateUser", null); ], UserController.prototype, "updateUser", null);
__decorate([ __decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER),
(0, common_1.Delete)(':id'), (0, common_1.Delete)(':id'),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__metadata("design:type", Function), __metadata("design:type", Function),
@@ -1004,6 +1390,7 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], UserController.prototype, "deleteUser", null); ], UserController.prototype, "deleteUser", null);
__decorate([ __decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.Post)('block/:id'), (0, common_1.Post)('block/:id'),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__metadata("design:type", Function), __metadata("design:type", Function),
@@ -1012,22 +1399,121 @@ __decorate([
], UserController.prototype, "toggleBlock", null); ], UserController.prototype, "toggleBlock", null);
UserController = __decorate([ UserController = __decorate([
(0, common_1.Controller)('users'), (0, common_1.Controller)('users'),
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
__metadata("design:paramtypes", [prisma_service_1.PrismaService]) __metadata("design:paramtypes", [prisma_service_1.PrismaService])
], UserController); ], UserController);
let CommentGateway = class CommentGateway {
handleConnection(client) {
console.log(`[WS] Client connected: ${client.id}`);
}
handleJoinTour(client, tourId) {
client.join(`tour_${tourId}`);
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
}
notifyNewComment(tourId, data) {
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
}
};
exports.CommentGateway = CommentGateway;
__decorate([
(0, websockets_1.WebSocketServer)(),
__metadata("design:type", socket_io_1.Server)
], CommentGateway.prototype, "server", void 0);
__decorate([
(0, websockets_1.SubscribeMessage)('joinTour'),
__metadata("design:type", Function),
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
__metadata("design:returntype", void 0)
], CommentGateway.prototype, "handleJoinTour", null);
exports.CommentGateway = CommentGateway = __decorate([
(0, websockets_1.WebSocketGateway)({ cors: { origin: '*' } })
], CommentGateway);
let CommentController = class CommentController {
constructor(prisma, commentGateway) {
this.prisma = prisma;
this.commentGateway = commentGateway;
}
async getComments(locationId) {
return this.prisma.comment.findMany({
where: { locationId },
include: {
user: { select: { name: true } }
},
orderBy: { createdAt: 'asc' }
});
}
async addComment(locationId, body, req) {
const comment = await this.prisma.comment.create({
data: {
content: body.content,
locationId,
userId: req.user.id
},
include: { user: { select: { name: true } } }
});
const location = await this.prisma.location.findUnique({
where: { id: locationId },
include: { leg: { select: { tourId: true } } }
});
if (location?.leg?.tourId) {
this.commentGateway.notifyNewComment(location.leg.tourId, {
...comment,
locationId
});
}
return comment;
}
};
__decorate([
(0, common_1.Get)(':locationId/comments'),
__param(0, (0, common_1.Param)('locationId', common_1.ParseUUIDPipe)),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String]),
__metadata("design:returntype", Promise)
], CommentController.prototype, "getComments", null);
__decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE, client_1.ParticipantRole.VIEWER_ONLY),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':locationId/comments'),
__param(0, (0, common_1.Param)('locationId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()),
__param(2, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise)
], CommentController.prototype, "addComment", null);
CommentController = __decorate([
(0, common_1.Controller)('locations'),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
CommentGateway])
], CommentController);
let AppModule = class AppModule { let AppModule = class AppModule {
}; };
AppModule = __decorate([ AppModule = __decorate([
(0, common_1.Module)({ (0, common_1.Module)({
imports: [ imports: [
cache_manager_1.CacheModule.registerAsync({
isGlobal: true,
useFactory: async () => ({
store: await (0, cache_manager_redis_yet_1.redisStore)({
url: process.env.REDIS_URL || 'redis://localhost:6379',
ttl: CACHE_TTL.DEFAULT,
}),
}),
}),
jwt_1.JwtModule.register({ jwt_1.JwtModule.register({
secret: process.env.JWT_SECRET || 'super-secret', secret: process.env.JWT_SECRET || 'super-secret',
signOptions: { expiresIn: '1d' }, signOptions: { expiresIn: '1d' },
}), }),
], ],
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController], controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController],
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, rbac_middleware_1.TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard], providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector],
exports: [prisma_service_1.PrismaService] exports: [prisma_service_1.PrismaService]
}) })
], AppModule); ], AppModule);
bootstrap(); bootstrap().catch(err => {
console.error('💥 Lỗi khởi động Server:');
console.error(err);
process.exit(1);
});
//# sourceMappingURL=main.js.map //# sourceMappingURL=main.js.map
+1 -1
View File
File diff suppressed because one or more lines are too long
+13 -3
View File
@@ -5,31 +5,41 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"db:generate": "dotenv -e ../.env -- prisma generate --schema=prisma/schema.prisma", "db:generate": "dotenv -e ../.env -- prisma generate --schema=prisma/schema.prisma",
"db:migrate": "dotenv -e ../.env -- prisma migrate dev --schema=prisma/schema.prisma", "db:migrate": "dotenv -e ../.env -- prisma migrate dev --schema=prisma/schema.prisma",
"db:seed": "node --loader ts-node/esm seed.ts" "db:seed": "dotenv -e ../.env -- tsx seed.ts"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^11.0.23", "@nestjs/cli": "^11.0.23",
"@types/bcrypt": "^5.0.2",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/pg": "^8.11.6", "@types/pg": "^8.11.6",
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.2",
"prisma": "^5.16.2", "prisma": "^5.16.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.5.3" "typescript": "^5.5.3"
}, },
"dependencies": { "dependencies": {
"@nestjs/cache-manager": "^3.1.3",
"@nestjs/common": "^11.1.27", "@nestjs/common": "^11.1.27",
"@nestjs/core": "^11.1.27", "@nestjs/core": "^11.1.27",
"@nestjs/jwt": "^11.0.2", "@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.27", "@nestjs/platform-express": "^11.1.27",
"@prisma/client": "^5.16.2", "@nestjs/platform-socket.io": "^11.1.27",
"@nestjs/websockets": "^11.1.27",
"@prisma/adapter-pg": "^5.16.2", "@prisma/adapter-pg": "^5.16.2",
"@prisma/client": "^5.16.2",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.1.5",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.12.0", "pg": "^8.12.0",
"redis": "^6.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2" "rxjs": "^7.8.2",
"sharp": "^0.35.1",
"socket.io": "^4.8.3"
} }
} }
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"locationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "tags" TEXT[];
+18 -3
View File
@@ -73,6 +73,8 @@ model User {
receivedJoinRequests JoinRequest[] @relation("JoinRequester") receivedJoinRequests JoinRequest[] @relation("JoinRequester")
uploadedPhotos Photo[] uploadedPhotos Photo[]
paidExpenses Expense[] @relation("ExpensePaidBy") paidExpenses Expense[] @relation("ExpensePaidBy")
comments Comment[]
} }
model Tour { model Tour {
@@ -88,6 +90,7 @@ model Tour {
childCount Int @default(0) childCount Int @default(0)
childDiscount Int @default(30) childDiscount Int @default(30)
tags String[]
createdById String createdById String
creator User @relation("TourCreator", fields: [createdById], references: [id]) creator User @relation("TourCreator", fields: [createdById], references: [id])
@@ -157,6 +160,7 @@ model Location {
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade) leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
expenses Expense[] expenses Expense[]
photos Photo[] photos Photo[]
comments Comment[]
} }
model Expense { model Expense {
@@ -176,15 +180,26 @@ model Expense {
model Photo { model Photo {
id String @id @default(uuid()) id String @id @default(uuid())
tourId String tourId String?
locationId String? locationId String?
uploaderId String uploaderId String
imageUrl String imageUrl String?
originalUrl String?
capturedAt DateTime @default(now()) capturedAt DateTime @default(now())
metadata Json? metadata Json?
privacy PrivacyLevel @default(TOUR_ONLY) privacy PrivacyLevel @default(TOUR_ONLY)
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade) tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
uploader User @relation(fields: [uploaderId], references: [id]) uploader User @relation(fields: [uploaderId], references: [id])
} }
model Comment {
id String @id @default(uuid())
content String
createdAt DateTime @default(now())
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
+25 -2
View File
@@ -1,8 +1,12 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg'; import { Pool } from 'pg';
import 'dotenv/config'; import * as dotenv from 'dotenv';
import * as bcrypt from 'bcrypt'; import * as path from 'path';
import bcrypt from 'bcrypt';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool); const adapter = new PrismaPg(pool);
@@ -90,6 +94,25 @@ async function main() {
}, },
}); });
console.log('--- Đang tạo bình luận mẫu... ---');
const dinhDocLap = await prisma.location.findFirst({ where: { name: 'Dinh Độc Lập' } });
if (dinhDocLap) {
await prisma.comment.createMany({
data: [
{
content: 'Chỗ này rất đẹp, giàu giá trị lịch sử!',
locationId: dinhDocLap.id,
userId: owner.id,
},
{
content: 'Nên đi vào buổi sáng cho mát mẻ mọi người nhé.',
locationId: dinhDocLap.id,
userId: photoMember.id,
}
],
});
}
console.log('--- Seed dữ liệu hoàn tất! ---'); console.log('--- Seed dữ liệu hoàn tất! ---');
console.log(`Email đăng nhập Owner: ${owner.email}`); console.log(`Email đăng nhập Owner: ${owner.email}`);
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`); console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
@@ -0,0 +1,91 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Inject } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import * as zlib from 'zlib';
import { promisify } from 'util';
import { HttpAdapterHost } from '@nestjs/core';
// Promisify các hàm nén/giải nén
const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);
// Ngưỡng nén: Chỉ nén nếu chuỗi JSON lớn hơn ngưỡng này (bytes)
// Nén dữ liệu quá nhỏ có thể làm tăng kích thước do overhead của header nén
const COMPRESSION_THRESHOLD = 100;
@Injectable()
export class CompressCacheInterceptor implements NestInterceptor {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private readonly httpAdapterHost: HttpAdapterHost, // Để truy cập request/response
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const request = context.getArgByIndex(0);
const response = context.getArgByIndex(1);
// Chỉ áp dụng cho các request GET
if (httpAdapter.getRequestMethod(request) !== 'GET') {
return next.handle();
}
const cacheKey = httpAdapter.getRequestUrl(request);
let cachedData = await this.cacheManager.get<Buffer>(cacheKey);
if (cachedData) {
try {
// Kiểm tra xem dữ liệu có phải là Buffer và có Gzip header (0x1f 0x8b) không
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
const decompressed = await gunzip(cachedData);
const jsonString = decompressed.toString('utf8');
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
return of(JSON.parse(jsonString));
} else {
// Dữ liệu không nén (lưu dưới dạng string hoặc buffer thường)
const jsonString = cachedData.toString();
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
return of(JSON.parse(jsonString));
}
} catch (e) {
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
// Nếu giải nén lỗi, coi như cache miss và xóa cache bị lỗi
await this.cacheManager.del(cacheKey);
}
}
// Cache miss hoặc giải nén lỗi, tiếp tục xử lý request
// Đặt header MISS ngay lập tức trước khi chạy logic Controller
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
return next.handle().pipe(
tap(async (data) => { // Sử dụng tap để thực hiện side effect (lưu cache) mà không thay đổi dữ liệu gốc
if (!data) return;
const jsonString = JSON.stringify(data);
const ttl = 60000; // TTL mặc định 1 phút (có thể cấu hình từ CACHE_TTL.DEFAULT)
if (jsonString.length > COMPRESSION_THRESHOLD) {
try {
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
await this.cacheManager.set(cacheKey, compressed, ttl);
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
// Không setHeader ở đây vì response có thể đã gửi xong
} catch (e) {
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
// Nếu nén lỗi, lưu dữ liệu không nén làm fallback
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
} else {
// Dữ liệu quá nhỏ, lưu không nén
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
})
);
}
}
+526 -13
View File
@@ -6,15 +6,45 @@ const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath }); dotenv.config({ path: envPath });
import 'reflect-metadata'; 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 { NestFactory } from '@nestjs/core';
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException } from '@nestjs/common'; 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 { NestExpressApplication } from '@nestjs/platform-express';
import { FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ParticipantRole } from '@prisma/client';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { AdminGuard } from './auth/admin.guard'; import { AdminGuard } from './auth/admin.guard';
import { JwtModule, JwtService } from '@nestjs/jwt'; import { JwtModule, JwtService } from '@nestjs/jwt';
import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { JwtStrategy } from './auth/jwt.strategy'; import { JwtStrategy } from './auth/jwt.strategy';
import { TourRoleGuard } from './common/rbac.middleware'; import { Reflector } from '@nestjs/core';
import { SetMetadata, CanActivate, ExecutionContext } from '@nestjs/common';
import { CacheModule, CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { redisStore } from 'cache-manager-redis-yet';
import { HttpAdapterHost } from '@nestjs/core';
import { CompressCacheInterceptor } from './common/compress-cache.interceptor';
// Promisify các hàm nén để sử dụng async/await
const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);
// Khai báo vị trí thư mục upload cụ thể
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads');
// Cấu hình TTL (mili giây) cho từng loại dữ liệu
const CACHE_TTL = {
DEFAULT: 600000, // 10 phút mặc định
RESOURCE_TO_TOUR: 3600000, // 1 giờ cho ánh xạ tài nguyên -> tour
USER_ROLE: 300000, // 5 phút cho quyền hạn người dùng
};
async function bootstrap() { async function bootstrap() {
if (!process.env.DATABASE_URL) { if (!process.env.DATABASE_URL) {
@@ -26,15 +56,131 @@ async function bootstrap() {
console.log('DATABASE_URL:', process.env.DATABASE_URL); console.log('DATABASE_URL:', process.env.DATABASE_URL);
console.log('===================================='); console.log('====================================');
const app = await NestFactory.create(AppModule); // Chuyển sang dùng NestExpressApplication để cấu hình static assets
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');
// Bật CORS để cho phép Frontend kết nối API không bị chặn // Bật CORS để cho phép Frontend kết nối API không bị chặn
app.enableCors(); app.enableCors();
// Tự động tạo thư mục upload nếu chưa tồn tại
if (!fs.existsSync(UPLOAD_ROOT)) {
fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
}
// Khai báo vị trí để ảnh upload có thể truy cập được từ bên ngoài qua URL
app.useStaticAssets(UPLOAD_ROOT, {
prefix: '/uploads/',
});
await app.listen(3001); await app.listen(3001);
console.log(`🚀 Server is running on: http://localhost:3001`); console.log(`🚀 Server is running on: http://localhost:3001`);
} }
// Define ROLES_KEY and Roles decorator
export const ROLES_KEY = 'roles';
export const Roles = (...roles: ParticipantRole[]) => SetMetadata(ROLES_KEY, roles);
// Implement TourRoleGuard (assuming it's here or similar to this)
// This guard checks if the user is a participant of the tour and has one of the required roles.
@Injectable()
export class TourRoleGuard implements CanActivate {
constructor(
private reflector: Reflector,
private prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredRoles = this.reflector.getAllAndOverride<ParticipantRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
// If no specific roles are required, default to OWNER and MANAGER for editing actions
const defaultRoles = [ParticipantRole.OWNER, ParticipantRole.MANAGER];
const rolesToCheck = requiredRoles && requiredRoles.length > 0 ? requiredRoles : defaultRoles;
const request = context.switchToHttp().getRequest();
const user = request.user; // User object from JwtAuthGuard
let tourId = request.params.tourId;
const resourceId = request.params.id || request.params.legId || request.params.locationId;
// Nếu không có tourId trực tiếp, tìm tourId thông qua các tài nguyên liên quan
if (!tourId && resourceId) {
const resCacheKey = `res-to-tour:${resourceId}`;
const compressedData = await this.cacheManager.get<Buffer>(resCacheKey);
if (compressedData) {
try {
const decompressed = await gunzip(compressedData);
tourId = decompressed.toString();
} catch (e) {
console.error('Lỗi giải nén cache:', e);
}
} else {
// Thử xem resourceId có phải là tourId không
const isTour = await this.prisma.tour.findUnique({ where: { id: resourceId }, select: { id: true } });
if (isTour) {
tourId = resourceId;
} else {
// Thử xem resourceId có phải là legId không
const leg = await this.prisma.leg.findUnique({ where: { id: resourceId }, select: { tourId: true } });
if (leg) {
tourId = leg.tourId;
} else {
// Thử xem resourceId có phải là locationId không
const loc = await this.prisma.location.findUnique({
where: { id: resourceId },
include: { leg: { select: { tourId: true } } }
});
if (loc) {
tourId = loc.leg.tourId;
} else {
// Thử xem resourceId có phải là photoId không
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true } });
if (photo) tourId = photo.tourId;
}
}
}
// Cache ánh xạ tài nguyên -> tour trong 1 giờ để giảm tải query ngược
if (tourId) {
try {
const compressed = await gzip(Buffer.from(tourId));
await this.cacheManager.set(resCacheKey, compressed, CACHE_TTL.RESOURCE_TO_TOUR);
} catch (e) {
await this.cacheManager.set(resCacheKey, tourId, CACHE_TTL.RESOURCE_TO_TOUR);
}
}
}
}
if (!user || !tourId) {
// Nếu đây là các route công khai hoặc không liên quan đến Tour, cho phép đi qua
// nhưng ở đây chúng ta đang áp dụng guard cho các route cần phân quyền Tour
if (!resourceId && !request.params.tourId) return true;
return false;
}
// Cache vai trò người dùng trong tour (5 phút)
const roleCacheKey = `user-role:${user.id}:${tourId}`;
let role = await this.cacheManager.get<ParticipantRole>(roleCacheKey);
if (!role) {
const participation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: user.id } },
});
if (!participation) return false;
role = participation.role;
await this.cacheManager.set(roleCacheKey, role, CACHE_TTL.USER_ROLE);
}
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.');
}
return true;
}
}
@Controller() @Controller()
class AppController { class AppController {
@@ -101,19 +247,69 @@ class AuthController {
} }
} }
@Controller('tours') // Controller mới để xử lý các tour công khai
class PublicTourController {
constructor(private prisma: PrismaService) {}
@UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache
@Get(':id/public')
async getPublicTourDetails(@Param('id', ParseUUIDPipe) id: string) {
console.log(`[PublicTour] Đang truy vấn chi tiết Tour ID: ${id}`);
const tour = await this.prisma.tour.findUnique({
where: { id },
include: {
participants: {
include: {
user: {
select: { id: true, name: true, email: true }
}
}
},
photos: true,
legs: {
orderBy: { sequence: 'asc' },
include: {
expenses: {
include: {
location: { select: { name: true, plannedStart: true } },
paidBy: { select: { name: true } }
}
},
locations: {
orderBy: { plannedStart: 'asc' },
include: { _count: { select: { comments: true } } }
},
},
},
},
});
if (!tour) {
console.warn(`[PublicTour] Không tìm thấy Tour với ID: ${id} trong Database`);
throw new NotFoundException(`Không tìm thấy Tour`);
}
return tour;
}
}
@Controller('tours') @Controller('tours')
class TourController { class TourController {
constructor(private prisma: PrismaService) {} constructor(
private prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) {}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Post() @Post()
async createTour(@Body() body: any, @Req() req: any) { async createTour(@Body() body: any, @Req() req: any) {
const { title, startDate, endDate, adultCount, childCount, childDiscount } = body; const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
return this.prisma.tour.create({ return this.prisma.tour.create({
data: { data: {
title, title,
description,
startDate: startDate ? new Date(startDate) : null, startDate: startDate ? new Date(startDate) : null,
endDate: endDate ? new Date(endDate) : null, endDate: endDate ? new Date(endDate) : null,
tags: tags || [],
adultCount: adultCount || 1, adultCount: adultCount || 1,
childCount: childCount || 0, childCount: childCount || 0,
childDiscount: childDiscount || 0, childDiscount: childDiscount || 0,
@@ -139,6 +335,7 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER) // Allow members to add locations
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/locations') @Post(':tourId/locations')
async addLocation(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) { async addLocation(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
@@ -181,6 +378,7 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can set start point
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/start-point') @Post(':tourId/start-point')
async updateTourStartPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) { async updateTourStartPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
@@ -218,6 +416,7 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can set end point
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/end-point') @Post(':tourId/end-point')
async updateTourEndPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) { async updateTourEndPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
@@ -253,6 +452,7 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can initialize legs
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/legs/batch') @Post(':tourId/legs/batch')
async initializeLegs(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { count: number }) { async initializeLegs(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { count: number }) {
@@ -298,6 +498,7 @@ class TourController {
return allLegs; return allLegs;
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add legs
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/legs') @Post(':tourId/legs')
async addLeg(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any) { async addLeg(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any) {
@@ -316,6 +517,7 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can update tour details
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Patch(':id') @Patch(':id')
async updateTour(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) { async updateTour(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
@@ -326,6 +528,7 @@ class TourController {
title: body.title, title: body.title,
description: body.description, description: body.description,
startDate: body.startDate ? new Date(body.startDate) : undefined, startDate: body.startDate ? new Date(body.startDate) : undefined,
tags: body.tags,
endDate: body.endDate ? new Date(body.endDate) : undefined, endDate: body.endDate ? new Date(body.endDate) : undefined,
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined, adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined, childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
@@ -334,16 +537,69 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER) // Only owner can delete tour
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Delete(':id') @Delete(':id')
async deleteTour(@Param('id', ParseUUIDPipe) id: string) { async deleteTour(@Param('id', ParseUUIDPipe) id: string) {
// Xóa tour sẽ xóa cascade các Leg, Location, Expense nhờ config onDelete: Cascade trong schema // 1. Lấy thông tin chi tiết Tour cùng các tài nguyên liên quan để dọn dẹp cache và file
const tour = await this.prisma.tour.findUnique({
where: { id },
include: {
participants: true,
photos: true,
legs: {
include: { locations: true }
}
}
});
if (!tour) throw new NotFoundException('Không tìm thấy tour');
// --- BẮT ĐẦU DỌN DẸP CACHE ---
// a. Xóa cache vai trò của tất cả thành viên trong tour này
for (const participant of tour.participants) {
await this.cacheManager.del(`user-role:${participant.userId}:${id}`);
}
// b. Xóa cache mapping tài nguyên (Leg, Location, Photo) về Tour này
await this.cacheManager.del(`res-to-tour:${id}`); // Bản thân tour
for (const leg of tour.legs) {
await this.cacheManager.del(`res-to-tour:${leg.id}`);
for (const loc of leg.locations) {
await this.cacheManager.del(`res-to-tour:${loc.id}`);
}
}
for (const photo of tour.photos) {
await this.cacheManager.del(`res-to-tour:${photo.id}`);
}
// --- KẾT THÚC DỌN DẸP CACHE ---
// 2. Xóa các file vật lý (ảnh 2K) trong thư mục uploads/tours
for (const photo of tour.photos) {
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
fs.unlinkSync(displayFilePath);
}
}
}
// Cập nhật DB: Xóa link ảnh 2K vì file vật lý đã bị xóa, tourId sẽ tự động SetNull
await this.prisma.photo.updateMany({
where: { tourId: id },
data: { imageUrl: null }
});
await this.prisma.tour.delete({ await this.prisma.tour.delete({
where: { id }, where: { id },
}); });
return { success: true }; return { success: true };
} }
// getPublicTours does not need TourRoleGuard as it's for any logged-in user to see their tours
@UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('explore') @Get('explore')
async getPublicTours(@Req() req: any) { async getPublicTours(@Req() req: any) {
@@ -356,17 +612,26 @@ class TourController {
}, },
take: 20, take: 20,
include: { include: {
participants: {
where: { userId: req.user.id },
select: { role: true }
},
photos: { take: 1 }, photos: { take: 1 },
legs: { legs: {
orderBy: { sequence: 'asc' }, orderBy: { sequence: 'asc' },
include: { include: {
locations: { orderBy: { plannedStart: 'asc' } } locations: {
orderBy: { plannedStart: 'asc' },
include: { _count: { select: { comments: true } } }
}
} }
} }
} }
}); });
} }
@UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY) // Any participant can view tour details
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Get(':id') @Get(':id')
async getTourDetails(@Param('id', ParseUUIDPipe) id: string) { async getTourDetails(@Param('id', ParseUUIDPipe) id: string) {
@@ -384,7 +649,16 @@ class TourController {
legs: { legs: {
orderBy: { sequence: 'asc' }, orderBy: { sequence: 'asc' },
include: { include: {
locations: { orderBy: { plannedStart: 'asc' } }, expenses: {
include: {
location: { select: { name: true, plannedStart: true } },
paidBy: { select: { name: true } }
}
},
locations: {
orderBy: { plannedStart: 'asc' },
include: { _count: { select: { comments: true } } }
},
}, },
}, },
}, },
@@ -394,12 +668,16 @@ class TourController {
return tour; return tour;
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add members
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/members') @Post(':tourId/members')
async addMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId: string; role?: string }, @Req() req: any) { async addMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId: string; role?: string }, @Req() req: any) {
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'] as const; const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'] as const;
const role = validRoles.includes(body.role as any) ? body.role as 'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY' : 'MEMBER'; const role = validRoles.includes(body.role as any) ? body.role as 'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY' : 'MEMBER';
// Xóa cache khi thay đổi quyền hạn hoặc thêm thành viên mới
await this.cacheManager.del(`user-role:${body.userId}:${tourId}`);
const participation = await this.prisma.tourParticipant.findUnique({ const participation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: body.userId } }, where: { tourId_userId: { tourId, userId: body.userId } },
}); });
@@ -444,6 +722,7 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can view join requests
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Get(':tourId/join-requests') @Get(':tourId/join-requests')
async getJoinRequests(@Param('tourId', ParseUUIDPipe) tourId: string, @Req() req: any) { async getJoinRequests(@Param('tourId', ParseUUIDPipe) tourId: string, @Req() req: any) {
@@ -459,6 +738,7 @@ class TourController {
return requests; return requests;
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY) // Any participant can create a join request for themselves or others (if they have permission)
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/join-requests') @Post(':tourId/join-requests')
async createJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId?: string }, @Req() req: any) { async createJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId?: string }, @Req() req: any) {
@@ -494,6 +774,7 @@ class TourController {
return joinRequest; return joinRequest;
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can accept join requests
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/join-requests/:requestId/accept') @Post(':tourId/join-requests/:requestId/accept')
async acceptJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) { async acceptJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) {
@@ -518,6 +799,8 @@ class TourController {
throw new BadRequestException('Yêu cầu này đã được xử lý trước đó.'); throw new BadRequestException('Yêu cầu này đã được xử lý trước đó.');
} }
await this.cacheManager.del(`user-role:${joinRequest.userId}:${tourId}`);
const existing = await this.prisma.tourParticipant.findUnique({ const existing = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: joinRequest.userId } }, where: { tourId_userId: { tourId, userId: joinRequest.userId } },
}); });
@@ -546,6 +829,7 @@ class TourController {
return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' }; return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' };
} }
@Roles(ParticipantRole.OWNER) // Only owner can reject join requests
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/join-requests/:requestId/reject') @Post(':tourId/join-requests/:requestId/reject')
async rejectJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) { async rejectJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) {
@@ -578,6 +862,7 @@ class TourController {
return { success: true, message: 'Đã từ chối yêu cầu tham gia.' }; return { success: true, message: 'Đã từ chối yêu cầu tham gia.' };
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can remove members
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Delete(':tourId/members/:userId') @Delete(':tourId/members/:userId')
async removeMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('userId', ParseUUIDPipe) userId: string) { async removeMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('userId', ParseUUIDPipe) userId: string) {
@@ -587,11 +872,67 @@ class TourController {
if (!participation) { if (!participation) {
throw new NotFoundException('Thành viên này không có trong tour'); throw new NotFoundException('Thành viên này không có trong tour');
} }
await this.cacheManager.del(`user-role:${userId}:${tourId}`);
await this.prisma.tourParticipant.delete({ await this.prisma.tourParticipant.delete({
where: { tourId_userId: { tourId, userId } }, where: { tourId_userId: { tourId, userId } },
}); });
return { message: 'Đã xóa thành viên khỏi tour' }; return { message: 'Đã xóa thành viên khỏi tour' };
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE) // All members can upload photos
@UseGuards(JwtAuthGuard, TourRoleGuard) // TourRoleGuard will now check for these roles
@Post(':tourId/photos')
@UseInterceptors(FilesInterceptor('images', 10)) // Chuyển sang memory storage để xử lý ảnh trước khi lưu
async uploadPhotos(@Param('tourId', ParseUUIDPipe) tourId: string, @UploadedFiles() files: any[], @Req() req: any) {
if (!files || files.length === 0) {
throw new BadRequestException('Vui lòng chọn ít nhất một ảnh');
}
const uploaderId = req.user.id;
// Đường dẫn ảnh gốc cho từng thành viên
const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals');
// Đường dẫn ảnh hiển thị chung của Tour
const tourDisplayPath = path.join(UPLOAD_ROOT, 'tours');
// Đảm bảo các thư mục tồn tại
if (!fs.existsSync(memberOriginalDir)) fs.mkdirSync(memberOriginalDir, { recursive: true });
if (!fs.existsSync(tourDisplayPath)) fs.mkdirSync(tourDisplayPath, { recursive: true });
return Promise.all(files.map(async (file) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const extension = path.extname(file.originalname).toLowerCase() || '.jpg';
const filename = `${uniqueSuffix}${extension}`;
const originalFilePath = path.join(memberOriginalDir, filename);
const displayFilePath = path.join(tourDisplayPath, filename);
// 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của thành viên
await fs.promises.writeFile(originalFilePath, file.buffer);
// 2. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px)
// Sử dụng Sharp để resize và tối ưu dung lượng ảnh
await sharp(file.buffer)
.resize(2560, 2560, {
fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K
withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh
})
.jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng
.toFile(displayFilePath);
// 3. Lưu thông tin vào Database (Lưu cả 2 đường dẫn)
return this.prisma.photo.create({
data: {
tourId: tourId,
uploaderId: uploaderId,
imageUrl: `/uploads/tours/${filename}`, // URL ảnh 2K dùng để render
originalUrl: `/uploads/members/${uploaderId}/originals/${filename}`, // URL ảnh gốc để tải xuống
privacy: 'TOUR_ONLY',
}
});
}));
}
} }
@Controller('locations') @Controller('locations')
@@ -652,7 +993,8 @@ class LocationController {
} }
@Controller('legs') @Controller('legs')
@UseGuards(JwtAuthGuard) @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Leg operations
@UseGuards(JwtAuthGuard, TourRoleGuard)
class LegController { class LegController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
@@ -699,6 +1041,8 @@ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: numbe
} }
@Controller('routing') @Controller('routing')
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Routing operations
@UseGuards(JwtAuthGuard, TourRoleGuard)
class RoutingController { class RoutingController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
@@ -811,7 +1155,50 @@ class RoutingController {
} }
} }
@Controller('photos')
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE) // All members can delete their own photos
@UseGuards(JwtAuthGuard, TourRoleGuard)
class PhotoController {
constructor(private prisma: PrismaService) {}
@Delete(':id')
async deletePhoto(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
const photo = await this.prisma.photo.findUnique({
where: { id },
});
if (!photo) {
throw new NotFoundException('Không tìm thấy ảnh.');
}
// Chỉ người tải lên mới có quyền xóa ảnh của họ
if (photo.uploaderId !== req.user.id) {
throw new ForbiddenException('Bạn không có quyền xóa ảnh này.');
}
// Xóa file 2K (imageUrl) nếu tồn tại
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
fs.unlinkSync(displayFilePath);
}
}
// Xóa file gốc (originalUrl) nếu tồn tại
if (photo.originalUrl) {
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
if (fs.existsSync(originalFilePath)) {
fs.unlinkSync(originalFilePath);
}
}
await this.prisma.photo.delete({ where: { id } });
return { message: 'Ảnh đã được xóa thành công.' };
}
}
@Controller('users') @Controller('users')
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for User management (Admin/Manager)
class UserController { class UserController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
@@ -832,6 +1219,20 @@ class UserController {
return users.filter((u: any) => u.id !== currentUserId); return users.filter((u: any) => u.id !== currentUserId);
} }
// getMyPhotos does not need TourRoleGuard as it's for the user's own photos
@UseGuards(JwtAuthGuard)
@Get('me/photos')
async getMyPhotos(@Req() req: any) {
return this.prisma.photo.findMany({
where: { uploaderId: req.user.id },
include: {
tour: { select: { title: true } }
},
orderBy: { capturedAt: 'desc' }
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for updating user (Admin/Manager)
@Patch(':id') @Patch(':id')
async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any) { async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any) {
if (data.password) { if (data.password) {
@@ -845,6 +1246,7 @@ class UserController {
}); });
} }
@Roles(ParticipantRole.OWNER) // Only owner can delete user
@Delete(':id') @Delete(':id')
async deleteUser(@Param('id', ParseUUIDPipe) id: string) { async deleteUser(@Param('id', ParseUUIDPipe) id: string) {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
@@ -853,11 +1255,36 @@ class UserController {
const adminCount = await this.prisma.user.count({ where: { isAdmin: true } }); const adminCount = await this.prisma.user.count({ where: { isAdmin: true } });
if (adminCount <= 1) throw new BadRequestException('Không thể xóa Quản trị viên cuối cùng'); if (adminCount <= 1) throw new BadRequestException('Không thể xóa Quản trị viên cuối cùng');
} }
// 1. Xác định thư mục chứa ảnh gốc của thành viên
const memberDir = path.join(UPLOAD_ROOT, 'members', id);
// 2. Tìm tất cả ảnh của user này để dọn dẹp nốt các bản 2K còn lại trong thư mục tours
const photos = await this.prisma.photo.findMany({
where: { uploaderId: id }
});
for (const photo of photos) {
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) fs.unlinkSync(displayFilePath);
}
}
// 2. Xóa các ràng buộc và dữ liệu trong DB
await this.prisma.photo.deleteMany({ where: { uploaderId: id } });
await this.prisma.tourParticipant.deleteMany({ where: { userId: id } }); await this.prisma.tourParticipant.deleteMany({ where: { userId: id } });
await this.prisma.user.delete({ where: { id } }); await this.prisma.user.delete({ where: { id } });
// 3. Xóa vật lý toàn bộ thư mục ảnh gốc
if (fs.existsSync(memberDir)) {
fs.rmSync(memberDir, { recursive: true, force: true });
}
return { message: 'Đã xóa người dùng' }; return { message: 'Đã xóa người dùng' };
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for blocking user (Admin/Manager)
@Post('block/:id') @Post('block/:id')
async toggleBlock(@Param('id', ParseUUIDPipe) id: string) { async toggleBlock(@Param('id', ParseUUIDPipe) id: string) {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
@@ -871,18 +1298,104 @@ class UserController {
} }
} }
@WebSocketGateway({ cors: { origin: '*' } })
export class CommentGateway implements OnGatewayConnection {
@WebSocketServer() server: Server;
handleConnection(client: Socket) {
console.log(`[WS] Client connected: ${client.id}`);
}
@SubscribeMessage('joinTour')
handleJoinTour(client: Socket, tourId: string) {
client.join(`tour_${tourId}`);
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
}
notifyNewComment(tourId: string, data: any) {
// Gửi thông báo tới tất cả client trong phòng của Tour này
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
}
}
@Controller('locations')
class CommentController {
constructor(
private prisma: PrismaService,
private commentGateway: CommentGateway
) {}
@Get(':locationId/comments')
// Cho phép khách xem bình luận mà không cần đăng nhập
async getComments(@Param('locationId', ParseUUIDPipe) locationId: string) {
return this.prisma.comment.findMany({
where: { locationId },
include: {
user: { select: { name: true } }
},
orderBy: { createdAt: 'asc' }
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY)
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':locationId/comments')
async addComment(
@Param('locationId', ParseUUIDPipe) locationId: string,
@Body() body: { content: string },
@Req() req: any
) {
const comment = await this.prisma.comment.create({
data: {
content: body.content,
locationId,
userId: req.user.id
},
include: { user: { select: { name: true } } }
});
// Tìm tourId để gửi thông báo vào đúng phòng
const location = await this.prisma.location.findUnique({
where: { id: locationId },
include: { leg: { select: { tourId: true } } }
});
if (location?.leg?.tourId) {
this.commentGateway.notifyNewComment(location.leg.tourId, {
...comment,
locationId // Gửi kèm locationId để UI biết điểm nào cần tăng số lượng
});
}
return comment;
}
}
@Module({ @Module({
imports: [ imports: [
CacheModule.registerAsync({
isGlobal: true,
useFactory: async () => ({
store: await redisStore({
url: process.env.REDIS_URL || 'redis://localhost:6379',
ttl: CACHE_TTL.DEFAULT, // Cấu hình TTL mặc định cho toàn bộ store
}),
}),
}),
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET || 'super-secret', secret: process.env.JWT_SECRET || 'super-secret',
signOptions: { expiresIn: '1d' }, signOptions: { expiresIn: '1d' },
}), }) as any,
], ],
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController], controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController],
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard], providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector],
exports: [PrismaService] exports: [PrismaService]
}) })
class AppModule {} class AppModule {}
bootstrap(); bootstrap().catch(err => {
console.error('💥 Lỗi khởi động Server:');
console.error(err);
process.exit(1);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

+1
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title> <title>Travel Planner</title>
</head> </head>
<body> <body>
+1
View File
@@ -16,6 +16,7 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0", "react-leaflet-cluster": "^2.1.0",
"socket.io-client": "^4.8.3",
"zustand": "^5.0.1" "zustand": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
+108 -66
View File
@@ -1,88 +1,130 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { LandingPage } from '@/pages/LandingPage'; import { LandingPage } from './pages/LandingPage';
import { TourDetailPage } from '@/pages/TourDetailPage'; import { ExploreMap } from './pages/ExploreMap';
import { ExploreMap } from '@/pages/ExploreMap'; import { TourDetailPage } from './pages/TourDetailPage';
import { SignupPage } from '@/pages/SignupPage'; import { SignupPage } from './pages/SignupPage';
import { useTourStore } from '@/store/useTourStore'; import { MyPhotosPage } from './pages/MyPhotosPage';
import { useTourStore } from './store/useTourStore';
import { ConfirmProvider } from './hooks/useConfirm';
import { NotificationProvider } from './hooks/useNotification';
function App() {
const params = new URLSearchParams(window.location.search);
const viewTourId = params.get('viewTour');
const App = () => {
type View = 'landing' | 'explore' | 'detail' | 'signup';
const [view, setView] = useState<View>('landing');
const [isInitialSetup, setIsInitialSetup] = useState(false);
const [user, setUser] = useState<any>(null); const [user, setUser] = useState<any>(null);
const [isUserLoaded, setIsUserLoaded] = useState(false); // Trạng thái để biết user đã được load từ localStorage chưa const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos'>(viewTourId ? 'tourDetail' : 'landing');
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
// Lấy action từ store
const fetchTour = useTourStore(state => state.fetchTour); const fetchTour = useTourStore(state => state.fetchTour);
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails);
useEffect(() => { useEffect(() => {
// Khôi phục phiên đăng nhập từ localStorage const params = new URLSearchParams(window.location.search);
const savedUser = localStorage.getItem('user'); const viewTourId = params.get('viewTour');
if (savedUser) {
const parsedUser = JSON.parse(savedUser); if (viewTourId) {
setUser(parsedUser); // Không gọi replaceState ngay để tránh mất ID khi component re-render hoặc refresh
} else {
// Kiểm tra đăng nhập bình thường nếu không có tham số viewTour
const token = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (token && storedUser) {
try {
setUser(JSON.parse(storedUser));
setCurrentPage('explore'); // Chuyển đến bản đồ khám phá nếu đã đăng nhập
} catch (e) {
console.error("Lỗi khi phân tích dữ liệu người dùng từ localStorage", e);
localStorage.removeItem('token');
localStorage.removeItem('user');
setCurrentPage('landing'); // Quay về trang Landing nếu dữ liệu lỗi
}
} else {
setCurrentPage('landing'); // Quay về trang Landing nếu chưa đăng nhập
}
} }
setIsUserLoaded(true); // Đánh dấu user đã được load }, []); // Chỉ chạy một lần khi component mount
// Kiểm tra xem hệ thống đã được cài đặt chưa const handleLoginSuccess = (loggedInUser: any) => {
fetch(`/api/v1/auth/status`) setUser(loggedInUser);
.then(res => res.ok ? res.json() : Promise.reject()) setCurrentPage('explore');
.then(data => setIsInitialSetup(!!data.isInitialSetup))
.catch(() => setIsInitialSetup(false));
}, []); // Chạy một lần khi component mount
// Effect để xử lý chuyển hướng nếu user đã đăng nhập và đang ở trang landing
useEffect(() => {
if (isUserLoaded && user && view === 'landing') {
setView('explore');
}
}, [isUserLoaded, user, view]);
const handleLoginSuccess = (userData: any) => {
setUser(userData);
}; };
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('user');
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user');
setUser(null); setUser(null);
setView('landing'); setCurrentPage('landing');
};
const handleViewTour = (tourId: string) => {
setCurrentTourId(tourId);
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
setCurrentPage('tourDetail');
};
const handleBackFromTourDetail = () => {
setCurrentTourId(null);
setIsPublicTourView(false);
// Quay về trang khám phá nếu đã đăng nhập, ngược lại quay về Landing
if (user) {
setCurrentPage('explore');
} else {
setCurrentPage('landing');
}
};
const handleBackFromSignup = () => {
setCurrentPage('landing');
};
const handleSignupSuccess = () => {
setCurrentPage('landing'); // Quay về trang Landing sau khi đăng ký thành công
}; };
return ( return (
<div className="app-container"> <ConfirmProvider>
{view === 'landing' && ( <NotificationProvider>
<LandingPage {(() => {
isInitialSetup={isInitialSetup} if (currentPage === 'tourDetail') {
onContinue={() => setView('explore')} return (
onGoToSignup={() => setView('signup')} <TourDetailPage
onGoToMap={() => setView('explore')} tourId={currentTourId!}
onLoginSuccess={handleLoginSuccess} onBack={handleBackFromTourDetail}
/> isPublicView={isPublicTourView}
)} />
);
}
{view === 'signup' && ( if (currentPage === 'explore') {
<SignupPage return (
onBack={() => setView('landing')} <ExploreMap
onSuccess={() => setView('landing')} onBack={handleBackFromTourDetail}
/> onLogout={handleLogout}
)} user={user}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
/>
);
}
{view === 'explore' && ( if (currentPage === 'myPhotos') {
<ExploreMap return (
onBack={() => setView('landing')} <MyPhotosPage onBack={() => setCurrentPage('explore')} />
onLogout={user ? handleLogout : undefined} );
user={user} }
onViewTour={(id) => {
fetchTour(id);
setView('detail');
}}
/>
)}
{view === 'detail' && ( if (currentPage === 'signup') {
<TourDetailPage onBack={() => setView('explore')} /> return <SignupPage onBack={handleBackFromSignup} onSuccess={handleSignupSuccess} />;
)} }
</div>
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
})()}
</NotificationProvider>
</ConfirmProvider>
); );
}; }
export default App; export default App;
+225 -20
View File
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useMemo } from 'react';
import { X, MapPin, Loader2, Clock, Map as MapIcon } from 'lucide-react'; import { format, parseISO } from 'date-fns';
import { useTourStore } from '@/store/useTourStore.js'; import { X, MapPin, Loader2, Clock, Map as MapIcon, Navigation, Search } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
import { useNotification } from '@/hooks/useNotification';
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
@@ -59,7 +61,7 @@ const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, cen
); );
}; };
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any }) => { export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation, isPublicView = false }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isPublicView?: boolean }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
address: '', address: '',
@@ -77,9 +79,14 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
plannedEnd: '' plannedEnd: ''
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [searchResults, setSearchResults] = useState<any[]>([]);
const [hasNoResults, setHasNoResults] = useState(false);
const [isSearching, setIsSearching] = useState(false);
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 // 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 } = useTourStore(); const { legs, addLocation, updateLocation, 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 // 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
useEffect(() => { useEffect(() => {
@@ -96,7 +103,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
type: editingLocation.type || 'VISIT', type: editingLocation.type || 'VISIT',
legId: editingLocation.legId || '', legId: editingLocation.legId || '',
note: editingLocation.note || '', note: editingLocation.note || '',
expenseAmount: expense?.amount?.toString() || '', expenseAmount: expense?.amount ? Number(expense.amount).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".") : '',
expenseCategory: expense?.category || 'OTHER', expenseCategory: expense?.category || 'OTHER',
expenseDescription: expense?.description || '', expenseDescription: expense?.description || '',
expenseNote: expense?.note || '', expenseNote: expense?.note || '',
@@ -117,11 +124,31 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
} }
}, [initialLegId, editingLocation, isOpen]); }, [initialLegId, editingLocation, isOpen]);
// Memoize tọa độ để tránh việc bản đồ tự động reset tâm khi re-render (ví dụ khi gõ tìm kiếm)
const currentCoords = useMemo<[number, number]>(
() => [formData.latitude, formData.longitude],
[formData.latitude, formData.longitude]
);
// Tự động cập nhật vị trí bản đồ theo chặng được chọn (Lấy điểm cuối của chặng đó làm tham chiếu)
useEffect(() => { useEffect(() => {
if (isOpen && !formData.name) { if (isOpen && !editingLocation && formData.legId && !formData.name) {
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] })); const selectedLeg = legs.find(l => l.id === formData.legId);
if (selectedLeg && selectedLeg.locations && selectedLeg.locations.length > 0) {
// Di chuyển đến địa điểm cuối cùng của chặng để người dùng thấy điểm nối tiếp
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
setFormData(prev => ({
...prev,
latitude: lastLoc.latitude,
longitude: lastLoc.longitude
}));
} else {
// Nếu chặng chưa có điểm nào, mặc định dùng vị trí trung tâm hiện tại của tour
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
}
} }
}, [isOpen, mapCenter]); }, [formData.legId, isOpen, editingLocation, legs, mapCenter]);
// 2. Thực hiện các tính toán và hàm xử lý // 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 currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
@@ -129,6 +156,55 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
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 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 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 handleSearchLocation = (query: string) => {
setFormData(prev => ({ ...prev, name: query }));
if (searchTimeout.current) clearTimeout(searchTimeout.current);
if (query.trim().length < 2) {
setSearchResults([]);
setIsSearching(false);
setHasNoResults(false);
return;
}
setIsSearching(true);
setHasNoResults(false);
searchTimeout.current = setTimeout(async () => {
try {
// Loại bỏ countrycodes=vn để tìm kiếm rộng hơn, thêm namedetails=1 để lấy tên chính xác
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=15&addressdetails=1&namedetails=1&accept-language=vi`, {
headers: {
'Accept-Language': 'vi'
}
});
const data = await res.json();
setSearchResults(data);
setHasNoResults(data.length === 0);
} catch (e) {
console.error("Lỗi tìm kiếm địa điểm:", e);
} finally {
setIsSearching(false);
}
}, 500);
};
const selectSearchResult = (result: any) => {
const lat = parseFloat(result.lat);
const lon = parseFloat(result.lon);
// Ưu tiên lấy tên từ namedetails nếu có, nếu không lấy phần đầu của display_name
const locationName = result.namedetails?.name || result.display_name.split(',')[0];
setFormData(prev => ({
...prev,
name: locationName,
address: result.display_name,
latitude: lat,
longitude: lon
}));
setSearchResults([]);
};
const handlePickLocation = async (latlng: L.LatLng) => { const handlePickLocation = async (latlng: L.LatLng) => {
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng })); setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
@@ -146,8 +222,60 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
} catch (e) {} } catch (e) {}
}; };
const handleUseCurrentLocation = () => {
if (!navigator.geolocation) {
notify({ title: 'Thông báo', message: "Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị GPS.", type: 'info' });
return;
}
// Kiểm tra môi trường Secure Context (HTTPS) - Bắt buộc cho Geolocation trên Mobile
if (!window.isSecureContext) {
alert("Tính năng định vị GPS yêu cầu kết nối bảo mật (HTTPS). Nếu bạn đang truy cập qua địa chỉ IP, vui lòng sử dụng HTTPS hoặc Localhost.");
return;
}
navigator.geolocation.getCurrentPosition(
async (pos) => {
const latlng = L.latLng(pos.coords.latitude, pos.coords.longitude);
const now = new Date();
const formattedTime = format(now, "yyyy-MM-dd'T'HH:mm");
setFormData(prev => ({
...prev,
latitude: latlng.lat,
longitude: latlng.lng,
plannedStart: formattedTime
}));
// Tự động thực hiện reverse geocoding để lấy tên địa điểm và địa chỉ
handlePickLocation(latlng);
},
(err) => {
let errorMessage = "Không thể lấy vị trí: ";
switch(err.code) {
case err.PERMISSION_DENIED:
errorMessage += "Bạn đã từ chối quyền truy cập vị trí.";
break;
case err.POSITION_UNAVAILABLE:
errorMessage += "Thông tin vị trí không khả dụng.";
break;
case err.TIMEOUT:
errorMessage += "Hết thời gian chờ yêu cầu định vị.";
break;
default: errorMessage += err.message;
}
notify({ title: 'Lỗi định vị', message: errorMessage, type: 'error' });
},
{
enableHighAccuracy: true, // Ưu tiên dùng GPS thay vì Wifi/Cell tower
timeout: 10000, // Chờ tối đa 10 giây
maximumAge: 0 // Không dùng vị trí cũ trong cache
}
);
};
// 3. Early return phải nằm SAU tất cả các khai báo Hook // 3. Early return phải nằm SAU tất cả các khai báo Hook
if (!isOpen) return null; if (!isOpen || isPublicView) return null; // Do not render if public view
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -155,6 +283,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
try { try {
const payload: any = { const payload: any = {
...formData, ...formData,
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
legId: currentLegId, legId: currentLegId,
latitude: parseFloat(formData.latitude as any), latitude: parseFloat(formData.latitude as any),
longitude: parseFloat(formData.longitude as any), longitude: parseFloat(formData.longitude as any),
@@ -167,7 +296,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
} }
onClose(); onClose();
} catch (error) { } catch (error) {
alert('Lỗi khi lưu địa điểm'); notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -187,22 +316,91 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
</div> </div>
{/* Mini Map Picker */} {/* Mini Map Picker */}
<div className="h-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group"> <div className="h-64 w-full rounded-3xl overflow-hidden mb-6 border border-gray-100 relative shadow-xl group">
<MapContainer center={[formData.latitude, formData.longitude]} zoom={13} className="h-full w-full"> {/* Map Search Bar Overlay - Tích hợp tìm kiếm trực tiếp trên bản đồ */}
<div className="absolute top-3 left-3 right-3 z-[1001] pointer-events-none">
<div className="relative max-w-sm pointer-events-auto">
<div className="relative group">
<input
type="text"
placeholder="Tìm địa điểm trên bản đồ..."
className="w-full pl-11 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold text-gray-800"
value={formData.name}
onChange={e => handleSearchLocation(e.target.value)}
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
{isSearching ? (
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
) : formData.name && (
<button
type="button"
onClick={() => { setFormData({...formData, name: ''}); setSearchResults([]); setHasNoResults(false); }}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-full text-gray-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Dropdown kết quả tìm kiếm ngay trong khung bản đồ */}
{(searchResults.length > 0 || hasNoResults) && (
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-40 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
{hasNoResults ? (
<div className="px-4 py-4 text-center text-gray-400 text-xs italic">Không tìm thấy đa điểm phù hợp...</div>
) : (
searchResults.map((result, idx) => (
<button
key={idx}
type="button"
onClick={() => selectSearchResult(result)}
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-colors flex flex-col gap-0.5"
>
<div className="flex items-center justify-between gap-2">
<div className="font-bold text-xs text-gray-900 truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
{result.type && (
<span className="text-[8px] font-black uppercase text-blue-400 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100 shrink-0">{result.type}</span>
)}
</div>
<div className="text-[10px] text-gray-500 truncate leading-tight">{result.display_name}</div>
</button>
))
)}
</div>
)}
</div>
</div>
<MapContainer center={currentCoords} zoom={13} className="h-full w-full" zoomControl={false}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<Marker position={[formData.latitude, formData.longitude]} /> <Marker position={currentCoords} />
<MapPicker center={[formData.latitude, formData.longitude]} onPick={handlePickLocation} /> <MapPicker center={currentCoords} onPick={handlePickLocation} />
</MapContainer> </MapContainer>
<div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-gray-500 shadow-sm border border-gray-100"> <div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-gray-500 shadow-sm border border-gray-100">
CHUỘT PHẢI Đ CHỌN VỊ TRÍ CHUỘT PHẢI Đ CHỌN VỊ TRÍ
</div> </div>
</div> </div>
{/* Nút lấy vị trí và thời gian hiện tại - Chỉ dành cho OWNER/MANAGER khi thêm mới */}
{!editingLocation && (userRole === 'OWNER' || userRole === 'MANAGER') && (
<button
type="button"
onClick={handleUseCurrentLocation}
className="w-full mb-6 py-4 bg-indigo-50 hover:bg-indigo-100 text-indigo-600 rounded-2xl flex items-center justify-center gap-2 text-xs font-black uppercase tracking-widest border border-indigo-100 transition-all active:scale-95 shadow-sm"
>
<Navigation className="w-4 h-4 fill-current" /> Sử dụng vị trí & thời gian hiện tại
</button>
)}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-1">Tên đa điểm</label> <label className="block text-sm font-bold text-gray-700 mb-1">Tên đa điểm</label>
<input required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none" <input
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} /> required
placeholder="Tên địa điểm..."
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all font-bold"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-1">Đa chỉ</label> <label className="block text-sm font-bold text-gray-700 mb-1">Đa chỉ</label>
@@ -220,9 +418,13 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label> <label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label>
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm" <input type="text" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
placeholder="0" placeholder="0"
value={formData.expenseAmount} onChange={e => setFormData({...formData, expenseAmount: e.target.value})} /> value={formData.expenseAmount} onChange={e => {
const rawValue = e.target.value.replace(/\D/g, ""); // Chỉ lấy số
const formattedValue = rawValue.replace(/\B(?=(\d{3})+(?!\d))/g, "."); // Thêm dấu chấm
setFormData({...formData, expenseAmount: formattedValue});
}} />
</div> </div>
<div> <div>
<label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label> <label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label>
@@ -269,7 +471,10 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none" <select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}> value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}>
{legs.map(leg => ( {legs.map(leg => (
<option key={leg.id} value={leg.id}>Chặng {leg.sequence}: {leg.note || 'Không có tên'}</option> <option key={leg.id} value={leg.id}>
Chặng {leg.sequence}: {leg.note || 'Không có tên'}
{leg.startDate ? ` (${format(parseISO(leg.startDate), 'dd/MM')})` : ''}
</option>
))} ))}
</select> </select>
</div> </div>
+19 -37
View File
@@ -1,5 +1,7 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react'; import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react';
import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
interface AddMemberModalProps { interface AddMemberModalProps {
isOpen: boolean; isOpen: boolean;
@@ -9,7 +11,8 @@ interface AddMemberModalProps {
joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>; joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>;
onRemoveMember?: (userId: string) => Promise<void>; onRemoveMember?: (userId: string) => Promise<void>;
onMemberAdded?: () => void; onMemberAdded?: () => void;
userRole?: string; userRole?: string; // User's role in the tour
isPublicView?: boolean; // New prop to indicate public view
} }
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => { export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
@@ -21,10 +24,11 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [fetchError, setFetchError] = useState(''); const [fetchError, setFetchError] = useState('');
const [submitError, setSubmitError] = useState(''); const [submitError, setSubmitError] = useState('');
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [confirmTarget, setConfirmTarget] = useState<{ userId: string; name: string } | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
const confirm = useConfirm();
const notify = useNotification();
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]); const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]); const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]);
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]); const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
@@ -65,19 +69,17 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const handleRemove = async (userId: string, memberName: string) => { const handleRemove = async (userId: string, memberName: string) => {
if (!onRemoveMember) return; if (!onRemoveMember) return;
setConfirmTarget({ userId, name: memberName }); const isConfirmed = await confirm({
setIsConfirmOpen(true); title: 'Xóa thành viên',
}; message: `Bạn có chắc chắn muốn xóa ${memberName} khỏi tour?`
});
const confirmRemove = async () => { if (isConfirmed) {
if (!confirmTarget || !onRemoveMember) return; try {
try { await onRemoveMember(userId);
await onRemoveMember(confirmTarget.userId); } catch (err: any) {
} catch (err: any) { setSubmitError(err.message || 'Không thể xóa thành viên');
setSubmitError(err.message || 'Không thể xóa thành viên'); }
} finally {
setIsConfirmOpen(false);
setConfirmTarget(null);
} }
}; };
@@ -98,7 +100,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
} }
await onMemberAdded(); await onMemberAdded();
} catch (err: any) { } catch (err: any) {
alert(err.message || 'Thao tác thất bại'); notify({ title: 'Lỗi', message: err.message || 'Thao tác thất bại', type: 'error' });
} finally { } finally {
setActionLoading(null); setActionLoading(null);
} }
@@ -134,7 +136,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
} }
}; };
if (!isOpen) return null; if (!isOpen || isPublicView) return null; // Do not render if public view
return ( return (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
@@ -318,26 +320,6 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</button> </button>
</div> </div>
</div> </div>
{isConfirmOpen && (
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={() => setIsConfirmOpen(false)} />
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
<h3 className="text-base font-bold text-gray-900">Xác nhận xóa thành viên</h3>
<p className="mt-2 text-sm text-gray-600">
Bạn chắc muốn xóa <span className="font-semibold text-gray-800">{confirmTarget?.name}</span> khỏi tour này?
</p>
<div className="mt-4 flex justify-end gap-2">
<button onClick={() => setIsConfirmOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
Hủy
</button>
<button onClick={confirmRemove} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
Xóa
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };
+168
View File
@@ -0,0 +1,168 @@
import React, { useState, useRef } from 'react';
import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useTourStore } from '@/store/useTourStore';
interface AddPhotoModalProps {
isOpen: boolean;
onClose: () => void;
tourId: string;
onSuccess?: () => void;
}
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess }) => {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
const fetchTour = useTourStore(state => state.fetchTour);
if (!isOpen) return null;
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
const newValidFiles: File[] = [];
const newValidPreviews: string[] = [];
for (const file of files) {
// 1. Kiểm tra cơ bản: Tệp có dung lượng hay không
if (!file || file.size === 0) {
notify({ title: 'Lỗi tệp', message: `Tệp ${file.name} trống hoặc không khả dụng.`, type: 'error' });
continue;
}
const previewUrl = URL.createObjectURL(file);
// 2. Kiểm tra tính toàn vẹn: Thử load ảnh vào bộ nhớ để xác nhận file "tồn tại" và đọc được
const isValidImage = await new Promise<boolean>((resolve) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = previewUrl;
});
if (isValidImage) {
newValidFiles.push(file);
newValidPreviews.push(previewUrl);
} else {
URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ
notify({ title: 'Lỗi ảnh', message: `Không thể đọc nội dung ảnh: ${file.name}. Vui lòng kiểm tra lại file.`, type: 'error' });
}
}
setSelectedFiles(prev => [...prev, ...newValidFiles]);
setPreviews(prev => [...prev, ...newValidPreviews]);
}
};
const removeFile = (index: number) => {
// Thu hồi URL khi xóa khỏi danh sách chờ để giải phóng bộ nhớ
URL.revokeObjectURL(previews[index]);
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
setPreviews(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedFiles.length === 0) return;
setIsUploading(true);
try {
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('images', file);
});
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
});
if (!response.ok) throw new Error('Upload failed');
notify({
title: 'Thành công',
message: `Đã tải lên ${selectedFiles.length} ảnh.`,
type: 'success'
});
fetchTour(tourId);
if (onSuccess) onSuccess();
onClose();
// Giải phóng bộ nhớ sau khi hoàn tất
previews.forEach(url => URL.revokeObjectURL(url));
setSelectedFiles([]);
setPreviews([]);
} catch (error) {
notify({
title: 'Lỗi',
message: 'Không thể tải ảnh lên. Vui lòng thử lại.',
type: 'error'
});
} finally {
setIsUploading(false);
}
};
return (
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<ImageIcon className="w-6 h-6 text-blue-600" /> Tải nh lên
</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-200 rounded-[32px] p-10 flex flex-col items-center justify-center cursor-pointer hover:bg-blue-50/50 hover:border-blue-200 transition-all mb-6 group"
>
<input type="file" ref={fileInputRef} className="hidden" multiple accept="image/*" onChange={handleFileChange} />
<div className="w-16 h-16 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-600 mb-4 group-hover:scale-110 transition-transform shadow-inner">
<Upload className="w-8 h-8" />
</div>
<p className="text-sm font-black text-gray-700">Nhấn đ chọn nh</p>
<p className="text-xs text-gray-400 mt-1 font-medium">Hỗ trợ JPG, PNG, WEBP</p>
</div>
{previews.length > 0 && (
<div className="flex-1 overflow-y-auto mb-6 pr-2">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3">Đã chọn {previews.length} tệp</p>
<div className="grid grid-cols-3 gap-3">
{previews.map((src, idx) => (
<div key={idx} className="relative aspect-square rounded-2xl overflow-hidden border border-gray-100 shadow-sm group">
<img src={src} className="w-full h-full object-cover" alt="preview" />
<button
type="button"
onClick={() => removeFile(idx)}
className="absolute top-1.5 right-1.5 p-1.5 bg-red-500/80 backdrop-blur-sm text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
<button
disabled={isUploading || selectedFiles.length === 0}
className="w-full py-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-black uppercase tracking-widest rounded-2xl flex items-center justify-center gap-2 shadow-lg shadow-blue-100 transition-all active:scale-95"
>
{isUploading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Xác nhận tải lên'}
</button>
</form>
</div>
</div>
);
};
+217
View File
@@ -0,0 +1,217 @@
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';
interface Comment {
id: string;
userName: string;
content: string;
createdAt: string;
userId: string;
}
interface CommentModalProps {
isOpen: boolean;
onClose: () => void;
locationId: string;
locationName: string;
onCommentAdded?: () => void; // Callback to update comment count on parent
onCommentDeleted?: () => void;
isPublicView?: boolean; // New prop to indicate public view
}
export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, locationId, locationName, onCommentAdded, onCommentDeleted, isPublicView = false }) => {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false);
const confirm = useConfirm();
const userRole = useTourStore(state => state.userRole);
const currentUserId = React.useMemo(() => {
try {
const user = JSON.parse(localStorage.getItem('user') || '{}');
return user.id;
} catch { return null; }
}, []);
const fetchComments = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
headers
});
if (res.ok) {
const data = await res.json();
setComments(data.map((c: any) => ({
id: c.id,
userName: c.user?.name || 'Ẩn danh',
content: c.content,
createdAt: c.createdAt,
userId: c.userId
})));
}
} catch (error) {
console.error('Lỗi khi tải bình luận:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!isOpen || !locationId) return;
fetchComments();
// Lắng nghe bình luận mới qua Proxy (không cần hardcode URL)
const socket = io();
socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể
socket.on('commentAdded', (newCommentData: any) => {
if (newCommentData.locationId === locationId) {
setComments(prev => {
// Tránh trùng lặp nếu chính mình gửi
if (prev.find(c => c.id === newCommentData.id)) return prev;
return [...prev, {
id: newCommentData.id,
userName: newCommentData.user?.name || 'Ẩn danh',
content: newCommentData.content,
createdAt: newCommentData.createdAt
}];
});
}
});
return () => { socket.disconnect(); };
}, [isOpen, locationId]);
const handleSend = async () => {
if (!newComment.trim()) return;
try {
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ content: newComment })
});
if (res.ok) {
setNewComment('');
fetchComments();
onCommentAdded?.();
}
} catch (error) {
console.error('Lỗi khi gửi bình luận:', error);
}
};
const handleDelete = async (commentId: string) => {
try {
const res = await fetch(`/api/v1/locations/comments/${commentId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (res.ok) {
setComments(prev => prev.filter(c => c.id !== commentId));
onCommentDeleted?.();
}
} catch (error) {
console.error('Lỗi khi xóa bình luận:', error);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[5000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onClose} />
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-white sticky top-0 z-10">
<div>
<h3 className="text-xl font-black text-gray-900 flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-600" />
Bình luận
</h3>
<p className="text-xs text-gray-500 font-bold uppercase tracking-widest mt-1 truncate max-w-[250px]">{locationName}</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* Comment List */}
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-50/50">
{isLoading ? (
<div className="flex justify-center py-10"><Loader2 className="w-6 h-6 animate-spin text-blue-600" /></div>
) : comments.length === 0 ? (
<div className="text-center py-10 text-gray-400 italic text-sm">Chưa bình luận nào.</div>
) : (
comments.map((c) => (
<div key={c.id} className="flex gap-3 animate-in slide-in-from-left-2 duration-300">
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0 border border-blue-200">
<User className="w-4 h-4 text-blue-600" />
</div>
<div className="flex-1">
<div className="bg-white p-3 rounded-2xl rounded-tl-none border border-gray-100 shadow-sm">
<div className="flex justify-between items-start mb-1">
<p className="text-xs font-black text-gray-900">{c.userName}</p>
{(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && (
<button
onClick={() => setConfirmState({ open: true, commentId: c.id })}
className="text-gray-400 hover:text-red-500 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<p className="text-sm text-gray-600 leading-relaxed">{c.content}</p>
</div>
<p className="text-[10px] text-gray-400 mt-1 ml-1 font-medium">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
))
)}
</div>
{/* Input Area */}
<div className="p-4 bg-white border-t border-gray-100">
<div className="relative flex items-center gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder={isPublicView ? 'Đăng nhập để bình luận...' : 'Viết bình luận...'}
className="flex-1 bg-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
/>
<button onClick={handleSend} disabled={!newComment.trim() || isPublicView} className="p-3 bg-blue-600 text-white rounded-2xl hover:bg-blue-700 disabled:opacity-50 disabled:bg-gray-300 transition-all active:scale-95 shadow-lg shadow-blue-100">
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
<ConfirmModal
isOpen={confirmState.open}
title="Xóa bình luận"
message="Bạn có chắc chắn muốn xóa bình luận này không? Hành động này sẽ không thể hoàn tác."
onConfirm={() => {
handleDelete(confirmState.commentId);
setConfirmState({ open: false, commentId: '' });
}}
onCancel={() => setConfirmState({ open: false, commentId: '' })}
/>
</div>
);
};
+35 -24
View File
@@ -1,39 +1,50 @@
import React, { useState } from 'react'; import React from 'react';
import { X } from 'lucide-react'; import { AlertTriangle, X } from 'lucide-react';
interface ConfirmModalProps { interface ConfirmModalProps {
isOpen: boolean; isOpen: boolean;
title?: string; title?: string;
message: string; message?: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
} }
export const ConfirmModal: React.FC<ConfirmModalProps> = ({ export const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, title, message, onConfirm, onCancel }) => {
isOpen,
title = 'Xác nhận',
message,
confirmText = 'Xác nhận',
cancelText = 'Hủy',
onConfirm,
onCancel,
}) => {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-[2200] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[6000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={onCancel} /> {/* Backdrop */}
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5"> <div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onCancel} />
<h3 className="text-base font-bold text-gray-900">{title}</h3>
<p className="mt-2 text-sm text-gray-600">{message}</p> {/* Modal Content */}
<div className="mt-4 flex justify-end gap-2"> <div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<button onClick={onCancel} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors"> <div className="flex justify-between items-center mb-4">
{cancelText} <div className="w-12 h-12 rounded-2xl bg-red-50 flex items-center justify-center text-red-500 shadow-inner">
<AlertTriangle className="w-6 h-6" />
</div>
<button onClick={onCancel} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button> </button>
<button onClick={onConfirm} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors"> </div>
{confirmText}
<h3 className="text-xl font-black text-gray-900 mb-2">{title || 'Xác nhận'}</h3>
<p className="text-sm text-gray-500 mb-8 leading-relaxed">
{message || 'Bạn có chắc chắn muốn thực hiện hành động này không?'}
</p>
<div className="grid grid-cols-2 gap-3">
<button
onClick={onCancel}
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
>
Hủy bỏ
</button>
<button
onClick={onConfirm}
className="py-4 bg-red-600 hover:bg-red-700 text-white font-bold rounded-2xl shadow-lg shadow-red-100 transition-all active:scale-95"
>
Xác nhận
</button> </button>
</div> </div>
</div> </div>
+73 -2
View File
@@ -1,9 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Trash2, Users } from 'lucide-react'; import { Trash2, Users, Tag as TagIcon } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => { export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
const [adultCount, setAdultCount] = useState(2); const [adultCount, setAdultCount] = useState(2);
@@ -12,6 +13,10 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const createTour = useTourStore((state) => state.createTour); const createTour = useTourStore((state) => state.createTour);
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [customTag, setCustomTag] = useState('');
const [members, setMembers] = useState<any[]>([]); const [members, setMembers] = useState<any[]>([]);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<any[]>([]); const [results, setResults] = useState<any[]>([]);
@@ -49,6 +54,20 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
setMembers((prev) => prev.filter((m) => m.id !== userId)); setMembers((prev) => prev.filter((m) => m.id !== userId));
}; };
const toggleTag = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
);
};
const addCustomTag = () => {
const tag = customTag.trim();
if (tag && !selectedTags.includes(tag)) {
setSelectedTags([...selectedTags, tag]);
setCustomTag('');
}
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
@@ -57,12 +76,14 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
const memberIds = members.map((m) => m.id); const memberIds = members.map((m) => m.id);
const tour = await createTour({ const tour = await createTour({
title, title,
description,
startDate, startDate,
endDate, endDate,
memberIds, memberIds,
adultCount, adultCount,
childCount, childCount,
childDiscount childDiscount,
tags: selectedTags
}); });
onSuccess(tour); onSuccess(tour);
onClose(); onClose();
@@ -94,6 +115,56 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
/> />
</div> </div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2 flex items-center gap-2">
<TagIcon className="w-4 h-4" /> Phân loại Tour
</label>
<div className="flex flex-wrap gap-2">
{availableTags.map(tag => (
<button
key={tag}
type="button"
onClick={() => toggleTag(tag)}
className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all border ${
selectedTags.includes(tag)
? 'bg-blue-600 text-white border-blue-600 shadow-md shadow-blue-100'
: 'bg-white text-gray-500 border-gray-200 hover:border-blue-300'
}`}
>
{tag}
</button>
))}
</div>
<div className="flex gap-2 mt-3">
<input
type="text"
value={customTag}
onChange={(e) => setCustomTag(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustomTag())}
placeholder="Thêm nhãn tùy chỉnh..."
className="flex-1 px-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={addCustomTag}
className="px-4 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all"
>
Thêm
</button>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1"> tả chuyến đi</label>
<textarea
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 resize-none text-sm"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Viết vài dòng giới thiệu về hành trình..."
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label> <label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label>
+46 -36
View File
@@ -56,16 +56,24 @@ export const ExpenseManager = () => {
const avgPerLeg = legs.length > 0 ? totalAmount / legs.length : 0; const avgPerLeg = legs.length > 0 ? totalAmount / legs.length : 0;
// Danh sách phẳng tất cả chi phí để hiển thị bảng // Danh sách phẳng tất cả chi phí để hiển thị bảng
const flatExpenses = (legs || []).flatMap(leg => const flatExpenses: any[] = [];
(leg.expenses || []).map((exp: any) => ({ [...(legs || [])].sort((a, b) => a.sequence - b.sequence).forEach((leg, legIdx) => {
const legExpenses = (leg.expenses || []).map((exp: any) => ({
...exp, ...exp,
legSequence: leg.sequence, legSequence: leg.sequence,
// Lấy ngày của Location nếu có, nếu không lấy ngày của Leg legDisplayIndex: legIdx + 1, // Số thứ tự chặng liên tục (1, 2, 3...)
date: exp.location?.plannedStart || leg.startDate || null date: exp.location?.plannedStart || leg.startDate || null
})) })).sort((a: any, b: any) => {
).sort((a, b) => { if (!a.date || !b.date) return 0;
if (!a.date || !b.date) return 0; return new Date(a.date).getTime() - new Date(b.date).getTime();
return new Date(a.date).getTime() - new Date(b.date).getTime(); });
legExpenses.forEach((exp: any, idx: number) => {
flatExpenses.push({
...exp,
isFirstInLeg: idx === 0
});
});
}); });
const childRateFactor = 1 - (Number(discount) / 100); const childRateFactor = 1 - (Number(discount) / 100);
@@ -126,10 +134,10 @@ export const ExpenseManager = () => {
// Cấu hình bảng dữ liệu // Cấu hình bảng dữ liệu
const tableColumn = ["STT", "Ngày giờ", "Chặng", "Dịch vụ", "Số tiền (VNĐ)", "Người chi"]; const tableColumn = ["STT", "Ngày giờ", "Chặng", "Dịch vụ", "Số tiền (VNĐ)", "Người chi"];
const tableRows = stats.list.map((exp: any, index: number) => [ const tableRows = stats.list.map((exp: any) => [
index + 1, exp.isFirstInLeg ? exp.legSequence : '',
exp.date ? format(new Date(exp.date), 'dd/MM HH:mm') : '--/--', exp.date ? format(new Date(exp.date), 'd/M - p') : '--/--',
`Chặng ${exp.legSequence}`, exp.isFirstInLeg ? `Chặng ${exp.legSequence}` : '',
exp.description || 'Không có mô tả', exp.description || 'Không có mô tả',
Number(exp.amount).toLocaleString(), Number(exp.amount).toLocaleString(),
exp.paidBy?.name || 'Chưa rõ' exp.paidBy?.name || 'Chưa rõ'
@@ -305,22 +313,35 @@ export const ExpenseManager = () => {
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
{stats.list.length > 0 ? ( {stats.list.length > 0 ? (
stats.list.map((exp: any, index: number) => ( stats.list.map((exp: any) => (
<tr key={exp.id} className="hover:bg-blue-50/20 transition-colors text-sm"> <tr key={exp.id} className={`hover:bg-blue-50/20 transition-colors text-sm ${!exp.isFirstInLeg ? 'bg-gray-50/10' : ''}`}>
<td className="px-4 py-4 text-center font-bold text-gray-400">{index + 1}</td> <td className="px-4 py-4 text-center font-black text-gray-400">
{exp.isFirstInLeg ? exp.legDisplayIndex : ''}
</td>
<td className="px-4 py-4"> <td className="px-4 py-4">
<div className="flex items-center gap-1.5 text-gray-600 font-medium"> <div className="flex items-center gap-1.5 text-gray-600 font-medium">
<Calendar className="w-3.5 h-3.5 opacity-40" /> <Calendar className="w-3.5 h-3.5 opacity-40" />
{exp.date ? format(new Date(exp.date), 'dd/MM HH:mm') : '--/--'} {exp.date ? format(new Date(exp.date), 'd/M - p') : '--/--'}
</div> </div>
</td> </td>
<td className="px-4 py-4 whitespace-nowrap"> <td className="px-4 py-4 whitespace-nowrap">
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-lg font-bold text-[10px]"> {exp.isFirstInLeg ? (
Chặng {exp.legSequence} <span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-lg font-bold text-[10px]">
</span> Chặng {exp.legSequence}
</span>
) : (
<div className="ml-6 border-l-2 border-blue-100/50 h-4" />
)}
</td> </td>
<td className="px-4 py-4 font-semibold text-gray-800"> <td className="px-4 py-4 font-semibold text-gray-800">
{exp.description || 'Không có mô tả'} {exp.location?.name ? (
<div className="flex flex-col">
<span className="text-gray-900">{exp.description || 'Chi phí dịch vụ'}</span>
<span className="text-[10px] text-blue-500 font-bold uppercase tracking-tighter">@{exp.location.name}</span>
</div>
) : (
exp.description || 'Không có mô tả'
)}
</td> </td>
<td className="px-4 py-4 text-right font-black text-blue-600"> <td className="px-4 py-4 text-right font-black text-blue-600">
{Number(exp.amount).toLocaleString()}đ {Number(exp.amount).toLocaleString()}đ
@@ -337,26 +358,15 @@ export const ExpenseManager = () => {
</tr> </tr>
)) ))
) : ( ) : (
// Khung bảng mẫu khi chưa có dữ liệu (Placeholder)
[1, 2, 3].map((i) => ( [1, 2, 3].map((i) => (
<tr key={`sample-${i}`} className="opacity-30 grayscale pointer-events-none select-none text-sm"> <tr key={`sample-${i}`} className="opacity-30 grayscale pointer-events-none select-none text-sm">
<td className="px-4 py-4 text-center font-bold text-gray-300">{i}</td> <td className="px-4 py-4 text-center font-bold text-gray-300">{i}</td>
<td className="px-4 py-4"> <td className="px-4 py-4 text-gray-300">--/-- --:--</td>
<div className="flex items-center gap-1.5 text-gray-300 font-medium"> <td className="px-4 py-4 text-gray-300">Chặng -</td>
<Calendar className="w-3.5 h-3.5 opacity-20" /> --/-- --:-- <td className="px-4 py-4 text-gray-300 italic">Chưa dữ liệu...</td>
</div> <td className="px-4 py-4 text-right text-gray-300">0đ</td>
</td> <td className="px-4 py-4 text-gray-300">Chưa </td>
<td className="px-4 py-4 whitespace-nowrap"> <td className="px-4 py-4 text-gray-200">-</td>
<span className="px-2 py-1 bg-gray-100 text-gray-400 rounded-lg font-bold text-[10px]">Chặng -</span>
</td>
<td className="px-4 py-4 text-gray-300 italic"> dụ: tham quan, Tiền ăn trưa...</td>
<td className="px-4 py-4 text-right font-black text-gray-300">0đ</td>
<td className="px-4 py-4">
<div className="flex items-center gap-1.5 text-gray-300">
<User className="w-3.5 h-3.5 opacity-20" /> Chưa dữ liệu
</div>
</td>
<td className="px-4 py-4 text-xs text-gray-200 italic">-</td>
</tr> </tr>
)) ))
)} )}
+103 -45
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns'; import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft } from 'lucide-react'; import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
import { ConfirmModal } from '@/components/ConfirmModal'; import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
import { CommentModal } from '@/components/CommentModal';
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => { const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
if (!actual) return null; if (!actual) return null;
@@ -38,8 +40,9 @@ const formatTravelTime = (minutes: number) => {
export const ItineraryTimeline = ({ export const ItineraryTimeline = ({
onAddLocation, onAddLocation,
onEditLocation onEditLocation,
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => { isPublicView = false
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void, isPublicView?: boolean }) => {
// Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết // 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 currentTour = useTourStore(state => state.currentTour);
const legs = useTourStore(state => state.legs); const legs = useTourStore(state => state.legs);
@@ -47,13 +50,50 @@ export const ItineraryTimeline = ({
const optimizeRouting = useTourStore(state => state.optimizeRouting); const optimizeRouting = useTourStore(state => state.optimizeRouting);
const addLeg = useTourStore(state => state.addLeg); const addLeg = useTourStore(state => state.addLeg);
const updateLeg = useTourStore(state => state.updateLeg); const updateLeg = useTourStore(state => state.updateLeg);
// Removed: const { isPublicView } = useTourStore(state => state); // isPublicView is passed as a prop
const deleteLeg = useTourStore(state => state.deleteLeg); const deleteLeg = useTourStore(state => state.deleteLeg);
const initializeLegs = useTourStore(state => state.initializeLegs); const initializeLegs = useTourStore(state => state.initializeLegs);
const fetchTour = useTourStore(state => state.fetchTour);
const deleteLocation = useTourStore(state => state.deleteLocation); const deleteLocation = useTourStore(state => state.deleteLocation);
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false }); // Khai báo logic canEdit để sử dụng trong toàn bộ component
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
const confirm = useConfirm();
const notify = useNotification();
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false); const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
const [tempLegCount, setTempLegCount] = useState(3); const [tempLegCount, setTempLegCount] = useState(3);
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
const [commentLocationId, setCommentLocationId] = useState('');
const [commentLocationName, setCommentLocationName] = useState('');
// Tối ưu hóa: Cập nhật UI ngay lập tức bằng cách can thiệp vào State của Store
const handleCommentIncrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map(leg => ({
...leg,
locations: leg.locations.map(loc =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
: loc
)
}));
// Dùng setState của Zustand để cập nhật một phần dữ liệu
useTourStore.setState({ legs: updatedLegs });
};
const handleCommentDecrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map(leg => ({
...leg,
locations: leg.locations.map(loc =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
: loc
)
}));
useTourStore.setState({ legs: updatedLegs });
};
// State cho Modal sửa chặng // State cho Modal sửa chặng
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
@@ -113,35 +153,31 @@ export const ItineraryTimeline = ({
}; };
const handleDeleteLeg = async (legId: string) => { const handleDeleteLeg = async (legId: string) => {
setConfirmState({ const isConfirmed = await confirm({
open: true,
title: 'Xóa chặng', title: 'Xóa chặng',
message: 'Bạn có chắc chắn muốn xóa chặng này?', message: 'Bạn có chắc chắn muốn xóa chặng này?'
onConfirm: async () => {
try {
await deleteLeg(legId);
} catch (err: any) {
alert(err.message);
} finally {
setConfirmState({ open: false });
}
},
}); });
if (isConfirmed) {
try {
await deleteLeg(legId);
} catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' });
}
}
}; };
const handleDeleteLocation = async (id: string) => { const handleDeleteLocation = async (id: string) => {
setConfirmState({ const isConfirmed = await confirm({
open: true,
title: 'Xóa địa điểm', title: 'Xóa địa điểm',
message: 'Bạn có chắc chắn muốn xóa địa điểm này?', message: 'Bạn có chắc chắn muốn xóa địa điểm này?'
onConfirm: async () => {
try {
await deleteLocation(id);
} catch (err: any) { alert(err.message); } finally {
setConfirmState({ open: false });
}
},
}); });
if (isConfirmed) {
try {
await deleteLocation(id);
} catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' });
}
}
}; };
return ( return (
@@ -170,10 +206,19 @@ export const ItineraryTimeline = ({
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2"> <div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
<div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1"> <div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm"> <span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm shrink-0">
{leg.sequence} {leg.sequence}
</span> </span>
{leg.note || `Chi tiết Chặng ${leg.sequence}`} <div className="flex flex-col overflow-hidden">
<span className="truncate leading-tight">{leg.note || `Chi tiết Chặng ${leg.sequence}`}</span>
{leg.startDate && (
<span className="text-[10px] text-gray-400 font-black uppercase tracking-wider flex items-center gap-1 mt-0.5">
<CalendarIcon className="w-2.5 h-2.5" />
{format(parseISO(leg.startDate), 'dd/MM/yyyy')}
{leg.endDate && leg.endDate !== leg.startDate && ` - ${format(parseISO(leg.endDate), 'dd/MM/yyyy')}`}
</span>
)}
</div>
</div> </div>
{prevLegLastLoc && ( {prevLegLastLoc && (
<div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10"> <div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10">
@@ -182,7 +227,7 @@ export const ItineraryTimeline = ({
)} )}
</div> </div>
<div className="flex items-center gap-2 ml-4"> <div className="flex items-center gap-2 ml-4">
{['OWNER', 'MANAGER'].includes(userRole || '') && ( {canEdit && (
<> <>
<button <button
onClick={() => onAddLocation?.(leg.id)} onClick={() => onAddLocation?.(leg.id)}
@@ -216,7 +261,7 @@ export const ItineraryTimeline = ({
</div> </div>
</div> </div>
)} )}
{totalDwellMinutes > 0 && ( {totalDwellMinutes > 0 && ( // Always show dwell time
<div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1"> <div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
Dừng: {formatTravelTime(totalDwellMinutes)} Dừng: {formatTravelTime(totalDwellMinutes)}
@@ -231,7 +276,7 @@ export const ItineraryTimeline = ({
<Zap className="w-3 h-3" /> <Zap className="w-3 h-3" />
Tối ưu Tối ưu
</button> </button>
)} )} {/* Only show optimize button if canEdit */}
</div> </div>
{/* Vertical Line for the whole leg */} {/* Vertical Line for the whole leg */}
@@ -309,7 +354,7 @@ export const ItineraryTimeline = ({
<div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1"> <div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1">
<div className="flex items-center gap-1 font-bold"> <div className="flex items-center gap-1 font-bold">
<Zap className="w-3 h-3" /> <Zap className="w-3 h-3" />
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ ({locationExpense.category})</span> <span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ</span>
</div> </div>
{locationExpense.description && ( {locationExpense.description && (
<div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div> <div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div>
@@ -325,7 +370,18 @@ export const ItineraryTimeline = ({
</div> </div>
<div className="text-right flex flex-col items-end"> <div className="text-right flex flex-col items-end">
<div className="flex items-center text-sm font-medium text-blue-600"> <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 items-center text-sm font-black text-blue-600">
<Clock className="w-3 h-3 mr-1" /> <Clock className="w-3 h-3 mr-1" />
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'} {location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
</div> </div>
@@ -334,7 +390,7 @@ export const ItineraryTimeline = ({
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')} Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
</div> </div>
)} )}
{['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && ( {canEdit && !isStartPoint && !isEndPoint && ( // Only show edit/delete if canEdit
<div className="flex gap-1 mt-2"> <div className="flex gap-1 mt-2">
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors"> <button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
<Edit2 className="w-3.5 h-3.5" /> <Edit2 className="w-3.5 h-3.5" />
@@ -378,7 +434,7 @@ export const ItineraryTimeline = ({
)} )}
{/* Actions at the bottom of the list */} {/* Actions at the bottom of the list */}
{['OWNER', 'MANAGER'].includes(userRole || '') && ( {canEdit && ( // Only show these buttons if canEdit
<div className="flex flex-col gap-3 pb-20 mt-8"> <div className="flex flex-col gap-3 pb-20 mt-8">
<button <button
onClick={handleDeclareLegs} onClick={handleDeclareLegs}
@@ -396,13 +452,6 @@ export const ItineraryTimeline = ({
</div> </div>
)} )}
</div> </div>
<ConfirmModal
isOpen={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={() => confirmState.onConfirm?.()}
onCancel={() => setConfirmState({ open: false })}
/>
{/* Modal Khai báo số chặng (Popover) */} {/* Modal Khai báo số chặng (Popover) */}
{isLegCountModalOpen && ( {isLegCountModalOpen && (
@@ -523,6 +572,15 @@ export const ItineraryTimeline = ({
</div> </div>
</div> </div>
)} )}
<CommentModal
isOpen={isCommentModalOpen}
onClose={() => setIsCommentModalOpen(false)}
locationId={commentLocationId}
locationName={commentLocationName}
isPublicView={isPublicView}
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
/>
</div> </div>
); );
}; };
+60
View File
@@ -0,0 +1,60 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { ConfirmModal } from '../components/ConfirmModal';
interface ConfirmOptions {
title?: string;
message?: string;
}
const ConfirmContext = createContext<((options: ConfirmOptions) => Promise<boolean>) | undefined>(undefined);
export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, setState] = useState<{
isOpen: boolean;
title?: string;
message?: string;
resolve?: (value: boolean) => void;
}>({ isOpen: false });
const confirm = useCallback((options: ConfirmOptions) => {
return new Promise<boolean>((resolve) => {
setState({
isOpen: true,
title: options.title,
message: options.message,
resolve,
});
});
}, []);
const handleConfirm = () => {
const resolve = state.resolve;
setState({ isOpen: false, resolve: undefined });
resolve?.(true);
};
const handleCancel = () => {
const resolve = state.resolve;
setState({ isOpen: false, resolve: undefined });
resolve?.(false);
};
return (
<ConfirmContext.Provider value={confirm}>
{children}
<ConfirmModal
isOpen={state.isOpen}
title={state.title}
message={state.message}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</ConfirmContext.Provider>
);
};
export const useConfirm = () => {
const confirm = useContext(ConfirmContext);
if (!confirm) throw new Error('useConfirm must be used within a ConfirmProvider');
return confirm;
};
+64
View File
@@ -0,0 +1,64 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { NotificationModal } from '../components/NotificationModal';
interface NotificationOptions {
title: string;
message: string;
type?: 'success' | 'error' | 'info';
}
const NotificationContext = createContext<((options: NotificationOptions) => void) | undefined>(undefined);
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, setState] = useState<{
isOpen: boolean;
title?: string;
message?: string;
type?: 'success' | 'error' | 'info';
}>({ isOpen: false });
const notify = useCallback(({ title, message, type = 'info' }: NotificationOptions) => {
setState({
isOpen: true,
title,
message,
type,
});
}, []);
const handleClose = useCallback(() => {
setState(prev => ({ ...prev, isOpen: false }));
}, []);
// Tự động đóng sau 3 giây nếu modal đang mở
useEffect(() => {
if (state.isOpen) {
const timer = setTimeout(() => {
handleClose();
}, 3000);
return () => clearTimeout(timer); // Xóa timer nếu người dùng bấm nút đóng trước 3 giây hoặc thông báo mới đè lên
}
}, [state.isOpen, handleClose]);
return (
<NotificationContext.Provider value={notify}>
{children}
<NotificationModal
isOpen={state.isOpen}
title={state.title}
message={state.message}
type={state.type}
onConfirm={handleClose}
/>
</NotificationContext.Provider>
);
};
export const useNotification = () => {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotification must be used within a NotificationProvider');
}
return context;
};
+333 -52
View File
@@ -1,12 +1,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, Tooltip } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster'; import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup; const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2 } from 'lucide-react'; import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon, MapPin, Loader2 } from 'lucide-react';
import { UserManagementModal } from '@/components/UserManagementModal'; import { UserManagementModal } from '@/components/UserManagementModal';
import { useNotification } from '@/hooks/useNotification';
import { CreateTourModal } from '../components/CreateTourModal'; import { CreateTourModal } from '../components/CreateTourModal';
// Fix lỗi icon mặc định của Leaflet // Fix lỗi icon mặc định của Leaflet
@@ -27,6 +28,16 @@ function RecenterMap({ position }: { position: [number, number] }) {
return null; return null;
} }
// Component Helper để đóng menu khi tương tác với bản đồ
function MapEvents({ onMapAction }: { onMapAction: () => void }) {
useMapEvents({
click: () => onMapAction(),
movestart: onMapAction,
dragstart: onMapAction,
});
return null;
}
// Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ // Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ
function MapTracker() { function MapTracker() {
const setMapCenter = useTourStore(state => state.setMapCenter); const setMapCenter = useTourStore(state => state.setMapCenter);
@@ -45,13 +56,15 @@ function MapTracker() {
return null; return null;
} }
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void }) => { export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void }) => {
// Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa // Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa
const publicTours = useTourStore(state => state.publicTours); const publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours); const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const fetchTour = useTourStore(state => state.fetchTour); const fetchTour = useTourStore(state => state.fetchTour);
const setMapCenter = useTourStore(state => state.setMapCenter); const setMapCenter = useTourStore(state => state.setMapCenter);
const notify = useNotification();
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM) // Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
const [initialViewState] = useState(() => { const [initialViewState] = useState(() => {
const saved = localStorage.getItem('map_view_state'); const saved = localStorage.getItem('map_view_state');
@@ -65,9 +78,126 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
const [mapZoom] = useState(initialViewState?.zoom || 13); const [mapZoom] = useState(initialViewState?.zoom || 13);
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false); const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
const [searchQuery, setSearchQuery] = useState(''); // State cho giá trị tìm kiếm
const [suggestions, setSuggestions] = useState<{ type: 'tour' | 'location', id: string, name: string, lat?: number, lon?: number }[]>([]);
const [isSearchingSuggestions, setIsSearchingSuggestions] = useState(false);
// Logic xử lý gợi ý tự động khi người dùng gõ
useEffect(() => {
const timer = setTimeout(async () => {
if (searchQuery.trim().length < 2) {
setSuggestions([]);
return;
}
setIsSearchingSuggestions(true);
try {
// 1. Lọc các Tour hiện có khớp với từ khóa
const tourMatches = publicTours
.filter(t => t.title.toLowerCase().includes(searchQuery.toLowerCase()))
.map(t => ({ type: 'tour' as const, id: t.id, name: t.title }));
// 2. Tìm kiếm địa điểm thực tế trên bản đồ qua OpenStreetMap
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=5&addressdetails=1&accept-language=vi`);
const data = await res.json();
const locationMatches = data.map((item: any) => ({
type: 'location' as const,
id: item.place_id,
name: item.display_name,
lat: parseFloat(item.lat),
lon: parseFloat(item.lon)
}));
// Hợp nhất kết quả: Tour ưu tiên lên đầu
setSuggestions([...tourMatches, ...locationMatches]);
} catch (err) {
console.error("Lỗi tìm kiếm gợi ý:", err);
} finally {
setIsSearchingSuggestions(false);
}
}, 500); // Debounce 500ms để tránh gọi API quá nhiều
return () => clearTimeout(timer);
}, [searchQuery, publicTours]);
const [selectedFilterTag, setSelectedFilterTag] = useState<string | null>(null);
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
// Tổng hợp nhãn từ danh sách Tour đang có để hiển thị bộ lọc đầy đủ (bao gồm cả nhãn tùy chỉnh)
const allFilterTags = React.useMemo(() => {
const tagsSet = new Set(availableTags);
publicTours.forEach(tour => {
tour.tags?.forEach((tag: string) => tagsSet.add(tag));
});
return Array.from(tagsSet);
}, [publicTours]);
// State cho menu chuột phải chia sẻ
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean } | null>(null);
const handleShare = (id: string, title: string) => {
const shareUrl = `${window.location.origin}?viewTour=${id}`;
if (navigator.share) {
navigator.share({
title: title,
text: `Khám phá hành trình du lịch: ${title}`,
url: shareUrl,
}).catch(() => {});
} else if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(shareUrl).then(() => {
notify({
title: 'Thành công',
message: 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!',
type: 'success'
});
});
} else {
// Giải pháp dự phòng cho môi trường không có HTTPS
const textArea = document.createElement("textarea");
textArea.value = shareUrl;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
notify({
title: 'Thành công',
message: 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!',
type: 'success'
});
} catch (err) {}
document.body.removeChild(textArea);
}
setShareMenu(null);
};
const handleSelectSuggestion = (s: any) => {
if (s.type === 'tour') {
onViewTour(s.id);
} else if (s.lat && s.lon) {
const pos: [number, number] = [s.lat, s.lon];
setUserPos(pos);
setMapCenter(pos);
notify({
title: 'Tìm thấy địa điểm',
message: `Đã di chuyển bản đồ tới: ${s.name.split(',')[0]}`,
type: 'success'
});
}
setSuggestions([]);
setSearchQuery('');
};
const filteredTours = React.useMemo(() => {
if (!selectedFilterTag) return publicTours;
return publicTours.filter(tour => tour.tags?.includes(selectedFilterTag));
}, [publicTours, selectedFilterTag]);
useEffect(() => { useEffect(() => {
fetchPublicTours(); // Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
if (user || localStorage.getItem('token')) {
fetchPublicTours();
}
// Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị // Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị
if (!initialViewState) { if (!initialViewState) {
@@ -87,52 +217,150 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
return ( return (
<div className="h-screen w-full relative"> <div className="h-screen w-full relative">
{/* Nút quay lại */} {/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */}
<button <div className="absolute top-4 left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none">
onClick={onBack} {/* Nhóm bên trái: Quay lại và Thông tin vị trí */}
className="absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all" <div className="flex items-center gap-3 pointer-events-auto">
> <button
<X className="w-6 h-6 text-gray-800" /> onClick={onBack}
</button> className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100"
title="Quay lại"
>
<X className="w-6 h-6 text-gray-800" />
</button>
{/* Nút đăng xuất - Chỉ hiển thị khi có user login */} {/* Nút lọc Tag và Dropdown */}
{onLogout && ( <div className="relative">
<button <button
onClick={onLogout} onClick={() => setIsFilterDropdownOpen(prev => !prev)}
className="absolute top-6 right-6 z-[1000] bg-white px-4 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" className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center"
> title="Lọc theo loại"
<LogOut className="w-5 h-5" /> >
<span className="hidden sm:inline">Đăng xuất</span> <Filter className="w-6 h-6 text-gray-800" />
</button> </button>
)}
{/* Nút quản lý người dùng cho Admin */} {/* Filter Dropdown Content */}
{user?.isAdmin && ( {isFilterDropdownOpen && (
<button <div className="absolute top-full left-0 mt-3 bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white/20 flex flex-col gap-2 max-w-[200px] z-[1003] animate-in slide-in-from-left-2 duration-200">
onClick={() => setIsAdminModalOpen(true)} <div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1">
className="absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold" <Filter className="w-3.5 h-3.5 text-blue-600" />
> <span className="text-[11px] font-black uppercase text-gray-500 tracking-wider">Lọc theo loại</span>
<Settings className="w-5 h-5" /> </div>
<span className="hidden sm:inline">Quản hệ thống</span> <div className="flex flex-col gap-1.5"> {/* Hiển thị tag theo chiều dọc */}
</button> <button
)} onClick={() => { setSelectedFilterTag(null); setIsFilterDropdownOpen(false); }}
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
>
Tất cả
</button>
{allFilterTags.map(tag => (
<button
key={tag}
onClick={() => { setSelectedFilterTag(tag === selectedFilterTag ? null : tag); setIsFilterDropdownOpen(false); }}
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
>
{tag}
</button>
))}
</div>
</div>
)}
</div>
{/* Nút tạo Tour mới */} {/* Search Box - Thay thế div "Khám phá khu vực" */}
{user && ( <div className="relative flex items-center bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white/20 flex-1 max-w-xs sm:max-w-md pointer-events-auto hidden sm:flex px-4 py-3">
<button <Navigation className="w-4 h-4 text-blue-600 mr-2 flex-shrink-0" />
onClick={() => setIsCreateModalOpen(true)} <input
className="absolute top-6 right-80 z-[1000] bg-green-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold" type="text"
> placeholder="Tìm kiếm địa điểm, tour..."
<Navigation className="w-5 h-5" /> className="flex-1 bg-transparent outline-none text-gray-800 text-sm font-medium"
<span className="hidden sm:inline">Tạo Tour mới</span> value={searchQuery}
</button> onChange={(e) => setSearchQuery(e.target.value)}
)} />
{isSearchingSuggestions && <Loader2 className="w-4 h-4 animate-spin text-blue-500 mr-2" />}
{searchQuery && (
<button onClick={() => { setSearchQuery(''); setSuggestions([]); }} className="p-1 text-gray-400 hover:text-gray-600 rounded-full">
<X className="w-4 h-4" />
</button>
)}
{/* Header Overlay */} {/* Dropdown danh sách gợi ý */}
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block"> {suggestions.length > 0 && (
<div className="flex items-center gap-2"> <div className="absolute top-full left-0 right-0 mt-3 bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl border border-white/20 overflow-hidden z-[1003] animate-in slide-in-from-top-2 duration-200">
<Navigation className="w-4 h-4 text-blue-600" /> {suggestions.map((s, idx) => (
<span className="font-bold text-gray-800">Đang khám phá khu vực của bạn</span> <button
key={`${s.type}-${s.id}-${idx}`}
onClick={() => handleSelectSuggestion(s)}
className="w-full text-left px-4 py-3 hover:bg-blue-50 flex items-center gap-3 transition-colors border-b border-gray-50 last:border-0"
>
<div className={`p-2 rounded-xl flex-shrink-0 ${s.type === 'tour' ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'}`}>
{s.type === 'tour' ? <ImageIcon className="w-4 h-4" /> : <MapPin className="w-4 h-4" />}
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-bold text-gray-800 truncate">{s.name}</span>
<span className="text-[10px] font-black uppercase text-gray-400 tracking-wider">
{s.type === 'tour' ? 'Chuyến đi của bạn' : 'Địa điểm trên bản đồ'}
</span>
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Nhóm bên phải: Các thao tác người dùng */}
<div className="flex items-center gap-2 pointer-events-auto">
{/* Nút Ảnh của tôi */}
{user && (
<button
onClick={() => {
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"
title="Ảnh của tôi"
>
<ImageIcon className="w-5 h-5" />
<span className="hidden md:inline text-sm">nh của tôi</span>
</button>
)}
{/* Nút tạo Tour mới */}
{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"
title="Tạo Tour mới"
>
<Navigation className="w-5 h-5" />
<span className="hidden md:inline text-sm">Tạo Tour</span>
</button>
)}
{/* Nút quản lý người dùng cho Admin */}
{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"
title="Quản lý hệ thống"
>
<Settings className="w-5 h-5" />
<span className="hidden md:inline text-sm">Hệ thống</span>
</button>
)}
{/* Nút đăng xuất */}
{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"
title="Đăng xuất"
>
<LogOut className="w-5 h-5" />
<span className="hidden md:inline text-sm">Rời đi</span>
</button>
)}
</div> </div>
</div> </div>
@@ -149,12 +377,14 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
{/* Theo dõi di chuyển bản đồ */} {/* Theo dõi di chuyển bản đồ */}
<MapTracker /> <MapTracker />
{/* Đóng menu và dropdown khi tương tác bản đồ */}
<MapEvents onMapAction={() => { setShareMenu(null); setSuggestions([]); setIsFilterDropdownOpen(false); }} />
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */} {/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
<RecenterMap position={userPos} /> <RecenterMap position={userPos} />
<MarkerClusterGroup chunkedLoading> <MarkerClusterGroup chunkedLoading>
{publicTours.map((tour) => { {filteredTours.map((tour) => {
const startLoc = tour.legs?.[0]?.locations?.[0]; const startLoc = tour.legs?.[0]?.locations?.[0];
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`; const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
const markerPos = startLoc const markerPos = startLoc
@@ -162,18 +392,32 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
: userPos; : userPos;
return ( return (
<React.Fragment key={tour.id}>
<Marker <Marker
key={tour.id}
position={markerPos} position={markerPos}
eventHandlers={{ eventHandlers={{
click: () => onViewTour(tour.id) click: () => onViewTour(tour.id),
contextmenu: (e) => {
// Kiểm tra quyền chia sẻ (OWNER, MANAGER, MEMBER)
const role = tour.participants?.[0]?.role;
const canShare = ['OWNER', 'MANAGER', 'MEMBER'].includes(role);
// Hiển thị menu tại vị trí chuột
setShareMenu({
x: e.containerPoint.x,
y: e.containerPoint.y,
id: tour.id,
title: tour.title,
canShare
});
}
}} }}
icon={L.divIcon({ icon={L.divIcon({
className: 'custom-bubble', className: 'custom-bubble',
html: ` html: `
<div class="relative group"> <div class="relative group">
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110"> <div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<img src="${tourImage}" class="w-full h-full object-cover" /> <img src="${tourImage}" class="w-full h-full object-cover" onerror="this.src='https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=100'"/>
</div> </div>
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white"> <div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
S S
@@ -183,13 +427,50 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
iconSize: [48, 48], iconSize: [48, 48],
iconAnchor: [24, 24] iconAnchor: [24, 24]
})} })}
/> >
</React.Fragment> <Tooltip direction="top" offset={[0, -20]} opacity={1}>
<div className="p-1 max-w-[180px]">
<div className="font-black text-blue-600 text-[11px] mb-0.5 uppercase tracking-tight truncate">{tour.title}</div>
{tour.tags && tour.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-1">
{tour.tags.map((tag: string) => (
<span key={tag} className="px-1.5 py-0.5 bg-blue-50 text-blue-500 rounded text-[8px] font-bold border border-blue-100">{tag}</span>
))}
</div>
)}
{tour.description && (
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic">
{tour.description}
</div>
)}
</div>
</Tooltip>
</Marker>
); );
})} })}
</MarkerClusterGroup> </MarkerClusterGroup>
</MapContainer> </MapContainer>
{/* Context Menu Chia sẻ */}
{shareMenu && (
<div
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200"
style={{ top: shareMenu.y, left: shareMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{shareMenu.canShare ? (
<button
onClick={() => handleShare(shareMenu.id, shareMenu.title)}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2 transition-colors"
>
<Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết
</button>
) : (
<div className="px-4 py-2 text-xs text-gray-400 italic">Bạn không quyền chia sẻ tour này</div>
)}
</div>
)}
{/* Admin Modal */} {/* Admin Modal */}
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} /> <UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
+301
View File
@@ -0,0 +1,301 @@
import React, { useEffect, useState, useMemo } from 'react';
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2 } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
const [photos, setPhotos] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedTourIdForPhoto, setSelectedTourIdForPhoto] = useState<string | 'all'>('all'); // Changed from filterTourId
const [filterDate, setFilterDate] = useState<string>('');
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest'); // 'newest' by default
const notify = useNotification();
const confirm = useConfirm();
useEffect(() => {
const fetchPhotos = async () => {
try {
const response = await fetch('/api/v1/users/me/photos', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!response.ok) throw new Error('Failed to fetch photos');
const data = await response.json();
setPhotos(data);
} catch (error) {
notify({ title: 'Lỗi', message: 'Không thể tải danh sách ảnh.', type: 'error' });
} finally {
setIsLoading(false);
}
};
fetchPhotos();
}, []);
// Lấy danh sách các Tour duy nhất để hiển thị trong bộ lọc
const toursWithPhotos = useMemo(() => {
const tourMap = new Map();
photos.forEach(p => {
if (p.tourId && p.tour) {
tourMap.set(p.tourId, { id: p.tourId, title: p.tour.title });
}
});
return Array.from(tourMap.values());
}, [photos]);
// Logic lọc ảnh tại Frontend
const filteredPhotos = useMemo(() => {
let photosToFilter = photos.filter(p => {
const matchTour = selectedTourIdForPhoto === 'all' || p.tourId === selectedTourIdForPhoto;
// So sánh ngày định dạng YYYY-MM-DD
const photoDate = p.capturedAt ? p.capturedAt.split('T')[0] : '';
const matchDate = !filterDate || photoDate === filterDate;
return matchTour && matchDate;
});
let sortedPhotos = photosToFilter;
// Sắp xếp ảnh
if (sortOrder === 'newest') {
sortedPhotos.sort((a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime());
} else { // 'oldest'
sortedPhotos.sort((a, b) => new Date(a.capturedAt).getTime() - new Date(b.capturedAt).getTime());
}
return sortedPhotos;
}, [photos, selectedTourIdForPhoto, filterDate, sortOrder]);
// Effect để thiết lập ảnh được chọn hiển thị hoặc reset nếu ảnh hiện tại không còn trong danh sách lọc
useEffect(() => {
if (filteredPhotos.length > 0 && (!selectedPhotoForDisplay || !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id))) {
setSelectedPhotoForDisplay(filteredPhotos[0]);
} else if (filteredPhotos.length === 0) {
setSelectedPhotoForDisplay(null);
} else if (selectedPhotoForDisplay) {
// Nếu ảnh đang chọn vẫn còn trong danh sách lọc, không làm gì cả
} else {
// Nếu không có ảnh nào để hiển thị
setSelectedPhotoForDisplay(null); // No photos to display
}
}, [filteredPhotos, selectedPhotoForDisplay]);
const handleDeletePhoto = async (photoId: string) => {
const isConfirmed = await confirm({
title: 'Xóa ảnh này?',
message: 'Bạn có chắc chắn muốn xóa ảnh này không? Hành động này không thể hoàn tác.'
});
if (!isConfirmed) return;
try {
const response = await fetch(`/api/v1/photos/${photoId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!response.ok) throw new Error('Failed to delete photo');
notify({ title: 'Thành công', message: 'Ảnh đã được xóa.', type: 'success' });
// Cập nhật lại danh sách ảnh sau khi xóa
setPhotos(prev => prev.filter(p => p.id !== photoId));
setSelectedPhotoForDisplay(null); // Reset ảnh đang hiển thị
} catch (error) {
notify({ title: 'Lỗi', message: 'Không thể xóa ảnh. Vui lòng thử lại.', type: 'error' });
}
};
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">nh của tôi</h1>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Kho lưu trữ nh gốc nhân</p>
</div>
</div>
{/* Filter Bar - Thanh công cụ lọc */}
<div className="bg-white border-b border-gray-100 px-6 py-4 flex flex-wrap items-center gap-4 sticky top-[73px] z-20 shadow-sm">
<div className="flex items-center gap-2 text-gray-500">
<Filter className="w-4 h-4" />
<span className="text-xs font-bold uppercase tracking-wider text-gray-400">Bộ lọc:</span>
</div>
{/* Chỉ báo Tour hiện tại */}
<div className="relative min-w-[150px]">
<div className="px-3 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold border border-blue-100 truncate max-w-[200px]">
{selectedTourIdForPhoto === 'all' ? 'Tất cả hành trình' : toursWithPhotos.find(t => t.id === selectedTourIdForPhoto)?.title || 'Tour đã chọn'}
</div>
</div>
{/* Lọc theo Thời gian */}
<div className="relative">
<input
type="date"
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
className="pl-3 pr-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500 transition-all text-gray-700 cursor-pointer"
/>
</div>
{/* Lọc theo Sắp xếp */}
<div className="relative min-w-[120px]">
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as 'newest' | 'oldest')}
className="w-full pl-3 pr-8 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500 transition-all appearance-none cursor-pointer text-gray-700"
>
<option value="newest">Mới nhất</option>
<option value="oldest"> nhất</option>
</select>
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
<ChevronLeft className="w-3 h-3 -rotate-90" />
</div>
</div>
{/* Reset Filters - Nút xóa nhanh lọc */}
{(selectedTourIdForPhoto !== 'all' || filterDate) && (
<button
onClick={() => { setSelectedTourIdForPhoto('all'); setFilterDate(''); }}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-bold text-red-500 hover:bg-red-50 rounded-xl transition-all"
>
<X className="w-3.5 h-3.5" />
Xóa lọc
</button>
)}
<div className="ml-auto">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-tighter">
Kết quả: <span className="text-blue-600">{filteredPhotos.length}</span> / {photos.length} nh
</p>
</div>
</div>
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
{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 kho nh...</p>
</div>
) : (
<div className="animate-in fade-in">
<div className="flex flex-col md:flex-row gap-4">
{/* Left Column: Tour List */}
<div className="md:w-1/4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex-shrink-0">
<h3 className="text-sm font-bold text-gray-800 mb-3">Tour của bạn</h3>
<div className="space-y-2">
<button
onClick={() => { setSelectedTourIdForPhoto('all'); setSelectedPhotoForDisplay(null); }}
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
selectedTourIdForPhoto === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Tất cả nh
</button>
{toursWithPhotos.map(tour => (
<button
key={tour.id}
onClick={() => { setSelectedTourIdForPhoto(tour.id); setSelectedPhotoForDisplay(null); }}
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
selectedTourIdForPhoto === tour.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tour.title}
</button>
))}
</div>
</div>
{/* Right Column: Large Photo Display */}
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
{selectedPhotoForDisplay ? (
<div className="relative w-full h-full flex flex-col items-center justify-center">
<img
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
alt="Selected Photo"
className="max-w-full max-h-[calc(100vh-350px)] object-contain rounded-xl shadow-md"
/>
<button
onClick={(e) => {
e.stopPropagation();
handleDeletePhoto(selectedPhotoForDisplay.id);
}}
className="absolute top-4 right-4 p-2 bg-red-500/80 backdrop-blur-sm text-white rounded-full shadow-lg hover:bg-red-600 transition-all active:scale-90"
title="Xóa ảnh này"
>
<Trash2 className="w-5 h-5" />
</button>
<div className="mt-6 w-full flex items-center justify-between px-2">
<div className="space-y-1">
<div className="flex items-center gap-2 text-gray-900 font-bold">
<MapPin className="w-4 h-4 text-blue-500" />
{selectedPhotoForDisplay.tour?.title || 'Không rõ hành trình'}
</div>
<div className="flex items-center gap-2 text-gray-400 text-xs font-medium uppercase tracking-wider">
<Calendar className="w-3.5 h-3.5" />
{new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</div>
</div>
{selectedPhotoForDisplay.originalUrl && (
<a
href={selectedPhotoForDisplay.originalUrl}
download
className="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-black uppercase tracking-widest text-[10px] transition-all shadow-lg shadow-blue-900/20 active:scale-95"
>
<Download className="w-4 h-4" /> Tải xuống nh gốc
</a>
)}
</div>
</div>
) : (
<div className="text-center text-gray-400 py-20">
<ImageIcon className="w-16 h-16 mx-auto mb-4 opacity-20" />
<p className="text-lg font-bold">Chọn một tấm nh đ xem</p>
</div>
)}
</div>
</div>
{/* Bottom Row: Thumbnails */}
<div className="mt-4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4">
<div className="flex items-center justify-between mb-4 px-1">
<h3 className="text-xs font-black text-gray-400 uppercase tracking-widest">Kho nh ({filteredPhotos.length})</h3>
</div>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-3 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
{filteredPhotos.map((photo: any) => (
<div
key={photo.id}
onClick={() => setSelectedPhotoForDisplay(photo)}
className={`aspect-square bg-gray-100 rounded-xl overflow-hidden relative group border-2 transition-all cursor-pointer ${
selectedPhotoForDisplay?.id === photo.id ? 'border-blue-500 scale-[0.98]' : 'border-transparent hover:border-blue-200'
}`}
>
<img
src={photo.imageUrl || photo.originalUrl}
alt="thumbnail"
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</div>
</div>
) }
<div className="h-4"></div>
<div className="py-32 text-center bg-white rounded-[40px] border-2 border-dashed border-gray-100">
<ImageIcon className="w-16 h-16 text-gray-200 mx-auto mb-4" />
<h3 className="text-xl font-bold text-gray-400">Chưa nh nào</h3>
<p className="text-sm text-gray-300">Hãy tham gia các chuyến đi lưu lại khoảnh khắc nhé!</p>
</div>
{}
</div>
</div>
);
};
+571 -138
View File
@@ -1,11 +1,14 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { io } from 'socket.io-client';
import { ItineraryTimeline } from '../components/ItineraryTimeline'; import { ItineraryTimeline } from '../components/ItineraryTimeline';
import { ExpenseManager } from '../components/ExpenseManager'; import { ExpenseManager } from '../components/ExpenseManager';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
import { AddLocationModal } from '@/components/AddLocationModal'; import { AddLocationModal } from '@/components/AddLocationModal';
import { AddMemberModal } from '../components/AddMemberModal'; import { AddMemberModal } from '../components/AddMemberModal';
import { ConfirmModal } from '../components/ConfirmModal'; import { useConfirm } from '@/hooks/useConfirm';
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal'; import { useNotification } from '@/hooks/useNotification';
import { CommentModal } from '@/components/CommentModal';
import { AddPhotoModal } from '@/components/AddPhotoModal';
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster'; import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup; const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
@@ -13,6 +16,7 @@ import {
Map as MapIcon, Map as MapIcon,
Wallet, Wallet,
Image as ImageIcon, Image as ImageIcon,
Upload,
Calendar, Calendar,
Users, Users,
ChevronLeft, ChevronLeft,
@@ -22,10 +26,16 @@ import {
List, List,
Map as MapIconLucide, Map as MapIconLucide,
MapPin, MapPin,
Search,
Loader2,
Flag, Flag,
Clock, Clock,
Check, Check,
X X,
MessageSquare,
Share2,
Tag as TagIcon,
Trash2
} from 'lucide-react'; } from 'lucide-react';
import L from 'leaflet'; import L from 'leaflet';
@@ -161,7 +171,7 @@ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.Lat
); );
}; };
export const TourDetailPage = ({ onBack }: { onBack: () => void }) => { export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBack: () => void, tourId: string, isPublicView?: boolean }) => {
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization // Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
const currentTour = useTourStore(state => state.currentTour); const currentTour = useTourStore(state => state.currentTour);
const legs = useTourStore(state => state.legs); const legs = useTourStore(state => state.legs);
@@ -173,10 +183,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline'); const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false); const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false); const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
const [targetLegId, setTargetLegId] = useState<string | null>(null); const [targetLegId, setTargetLegId] = useState<string | null>(null);
const [editingLocation, setEditingLocation] = useState<any>(null); const [editingLocation, setEditingLocation] = useState<any>(null);
const [selectedMember, setSelectedMember] = useState<any>(null); const [selectedMember, setSelectedMember] = useState<any>(null);
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false); const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
const currentUserId = useMemo(() => {
const token = localStorage.getItem('token');
if (!token) return null;
try {
return JSON.parse(atob(token.split('.')[1])).sub;
} catch (e) {
return null;
}
}, []);
const [joinRequests, setJoinRequests] = useState<any[]>([]); const [joinRequests, setJoinRequests] = useState<any[]>([]);
const [titleInput, setTitleInput] = useState(currentTour?.title ?? ''); const [titleInput, setTitleInput] = useState(currentTour?.title ?? '');
const [descriptionInput, setDescriptionInput] = useState(currentTour?.description ?? ''); const [descriptionInput, setDescriptionInput] = useState(currentTour?.description ?? '');
@@ -184,10 +205,128 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
const [adultCountInput, setAdultCountInput] = useState(currentTour?.adultCount ?? 0); const [adultCountInput, setAdultCountInput] = useState(currentTour?.adultCount ?? 0);
const [childCountInput, setChildCountInput] = useState(currentTour?.childCount ?? 0); const [childCountInput, setChildCountInput] = useState(currentTour?.childCount ?? 0);
const [childDiscountInput, setChildDiscountInput] = useState(currentTour?.childDiscount ?? 0); const [childDiscountInput, setChildDiscountInput] = useState(currentTour?.childDiscount ?? 0);
const [tagsInput, setTagsInput] = useState<string[]>(currentTour?.tags ?? []);
const [customTag, setCustomTag] = useState('');
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
const [selectedLegIdForPhoto, setSelectedLegIdForPhoto] = useState<string | 'all'>('all'); // Keep this state
// Memoized filtered photos based on selectedLegIdForPhoto
const filteredPhotos = useMemo(() => {
if (!currentTour?.photos) return [];
let photosToFilter = currentTour.photos;
if (selectedLegIdForPhoto !== 'all') {
const targetLeg = legs.find(l => l.id === selectedLegIdForPhoto);
if (targetLeg) {
const locationIdsInLeg = targetLeg.locations.map((loc: any) => loc.id);
// Filter photos that have a locationId and that locationId is in the current leg
photosToFilter = photosToFilter.filter((p: any) => p.locationId && locationIdsInLeg.includes(p.locationId));
} else {
photosToFilter = []; // If leg not found, no photos
}
}
return photosToFilter;
}, [currentTour?.photos, selectedLegIdForPhoto, legs]);
// Effect to set initial selected photo for display or reset if current one is no longer in filtered list
useEffect(() => {
if (filteredPhotos.length > 0 && !selectedPhotoForDisplay) {
setSelectedPhotoForDisplay(filteredPhotos[0]);
} else if (selectedPhotoForDisplay && !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id)) {
setSelectedPhotoForDisplay(filteredPhotos.length > 0 ? filteredPhotos[0] : null);
}
}, [filteredPhotos, selectedPhotoForDisplay]);
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
const [commentLocationId, setCommentLocationId] = useState('');
const [commentLocationName, setCommentLocationName] = useState('');
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null); const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
// State tìm kiếm cho chế độ Bản đồ trong Tab Lộ trình
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false);
const handleSearchLocation = async (query: string) => {
setSearchQuery(query);
if (query.trim().length < 2) {
setSearchResults([]);
return;
}
setIsSearching(true);
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&addressdetails=1&namedetails=1&accept-language=vi`);
const data = await res.json();
setSearchResults(data);
} catch (e) {
console.error("Lỗi tìm kiếm:", e);
} finally {
setIsSearching(false);
}
};
const handleDeletePhoto = async (photoId: string) => {
const isConfirmed = await confirm({
title: 'Xóa ảnh này?',
message: 'Bạn có chắc chắn muốn xóa ảnh này khỏi chuyến đi? Hành động này không thể hoàn tác.'
});
if (!isConfirmed) return;
try {
const response = await fetch(`/api/v1/photos/${photoId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!response.ok) throw new Error('Không thể xóa ảnh');
notify({ title: 'Thành công', message: 'Đã xóa ảnh.', type: 'success' });
fetchTour(tourId); // Re-fetch tour to update photo list
setSelectedPhotoForDisplay(null); // Reset selected photo after deletion
} catch (error) {
notify({ title: 'Lỗi', message: 'Không thể xóa ảnh. Vui lòng thử lại.', type: 'error' });
}
};
const fetchTour = useTourStore(state => state.fetchTour); const fetchTour = useTourStore(state => state.fetchTour);
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action
useEffect(() => {
if (currentTour) {
setTagsInput(currentTour.tags || []);
}
}, [currentTour]);
// Hàm tối ưu để cập nhật số lượng bình luận mà không cần fetch lại toàn bộ Tour
const handleCommentIncrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map(leg => ({
...leg,
locations: leg.locations.map(loc =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
: loc
)
}));
// Cập nhật trực tiếp vào Store
useTourStore.setState({ legs: updatedLegs });
};
const handleCommentDecrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map(leg => ({
...leg,
locations: leg.locations.map(loc =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
: loc
)
}));
useTourStore.setState({ legs: updatedLegs });
};
const fetchPublicTours = useTourStore(state => state.fetchPublicTours); const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const setMapCenter = useTourStore(state => state.setMapCenter); const setMapCenter = useTourStore(state => state.setMapCenter);
const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint); const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint);
@@ -200,7 +339,8 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest); const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest);
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest); const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
const notificationModal = useNotificationModal(); const confirm = useConfirm();
const notify = useNotification();
// Khôi phục vị trí và mức zoom từ localStorage // Khôi phục vị trí và mức zoom từ localStorage
const [initialViewState] = useState(() => { const [initialViewState] = useState(() => {
@@ -212,17 +352,44 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
}); });
// SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi // SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi
// Nếu là public view, không có quyền chỉnh sửa
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || ''); const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
const canInvite = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || ''); const canInvite = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
const isOwner = userRole === 'OWNER'; const canUploadPhoto = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE'].includes(userRole || '');
const isOwner = isPublicView ? false : userRole === 'OWNER';
const canShare = isPublicView || ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
useEffect(() => { useEffect(() => {
if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) { if (isPublicView) {
fetchPublicTourDetails(tourId);
} else if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([])); fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([]));
} }
}, [currentTour, userRole]); // Fetch tour details when tourId changes or public view status changes
if (tourId) { isPublicView ? fetchPublicTourDetails(tourId) : fetchTour(tourId); }
}, [tourId, isPublicView, userRole]); // Add tourId to dependencies
// New useEffect to manage initial photo display when currentTour or legs change
useEffect(() => {
if (currentTour && currentTour.photos && currentTour.photos.length > 0) {
// If there are photos, and no leg is selected, default to 'all' and first photo
if (selectedLegIdForPhoto === 'all' && !selectedPhotoForDisplay) {
setSelectedPhotoForDisplay(currentTour.photos[0]);
} else if (selectedLegIdForPhoto !== 'all') {
// If a specific leg is selected, try to find a photo for that leg
const targetLeg = legs.find(l => l.id === selectedLegIdForPhoto);
if (targetLeg) {
const locationIdsInLeg = targetLeg.locations.map((loc: any) => loc.id);
const photosInLeg = currentTour.photos.filter((p: any) => p.locationId && locationIdsInLeg.includes(p.locationId));
if (photosInLeg.length > 0 && !selectedPhotoForDisplay) {
setSelectedPhotoForDisplay(photosInLeg[0]);
} else if (selectedPhotoForDisplay && !photosInLeg.some(p => p.id === selectedPhotoForDisplay.id)) {
setSelectedPhotoForDisplay(photosInLeg.length > 0 ? photosInLeg[0] : null);
}
}
}
}
}, [currentTour, legs, selectedLegIdForPhoto, selectedPhotoForDisplay]);
const [mapZoom] = useState(initialViewState?.zoom || 13); const [mapZoom] = useState(initialViewState?.zoom || 13);
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa // Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
@@ -232,21 +399,74 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
if (initialViewState) { if (initialViewState) {
setMapCenter(initialViewState.center); setMapCenter(initialViewState.center);
} }
const loadData = async () => { // This useEffect is for initial load, but we now have tourId prop
// Nếu chưa có tour nào trong store, thử tải danh sách public trước // The fetching logic is moved to the useEffect above that depends on tourId and isPublicView
if (publicTours.length === 0) { // So this useEffect can be simplified or removed if its only purpose was initial data load.
await fetchPublicTours(); // if (publicTours.length === 0 && !isPublicView) { // Only fetch public tours if not in public view and not already loaded
} // fetchPublicTours();
}; // }
loadData(); }, [initialViewState]); // Removed publicTours, currentTour, fetchPublicTours, fetchTour from dependencies
}, []);
useEffect(() => {
// Lấy tour đầu tiên từ danh sách khám phá để hiển thị demo const handleShare = () => {
if (publicTours.length > 0 && !currentTour) { if (!currentTour) return;
fetchTour(publicTours[0].id); // Tạo link với query param ?viewTour=...
const shareUrl = `${window.location.origin}?viewTour=${currentTour.id}`;
if (navigator.share) {
navigator.share({
title: currentTour.title,
url: shareUrl,
}).catch(() => {});
} else if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(shareUrl).then(() => {
notify({
title: 'Thành công',
message: 'Đã sao chép liên kết chia sẻ chuyến đi!',
type: 'success'
});
});
} else {
// Giải pháp dự phòng cho môi trường không có HTTPS (truy cập qua IP)
const textArea = document.createElement("textarea");
textArea.value = shareUrl;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
notify({
title: 'Thành công',
message: 'Đã sao chép liên kết chia sẻ chuyến đi!',
type: 'success'
});
} catch (err) {}
document.body.removeChild(textArea);
} }
}, [publicTours, currentTour, fetchTour]); };
// Thiết lập kết nối WebSocket Real-time
useEffect(() => {
if (!currentTour) return;
const socket = io(); // Kết nối qua Proxy của Vite (cùng origin)
socket.on('connect', () => {
socket.emit('joinTour', currentTour.id);
});
socket.on('commentAdded', (data: any) => {
// Cập nhật UI ngay lập tức khi bất kỳ ai bình luận
handleCommentIncrement(data.locationId);
});
return () => { socket.disconnect(); };
}, [currentTour?.id]);
// This useEffect is for initial demo loading, might not be needed if tourId is always passed
// useEffect(() => {
// if (publicTours.length > 0 && !currentTour && !isPublicView) {
// fetchTour(publicTours[0].id);
// }
// }, [publicTours, currentTour, fetchTour, isPublicView]);
@@ -351,11 +571,16 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
adultCount: adultCountInput, adultCount: adultCountInput,
childCount: childCountInput, childCount: childCountInput,
childDiscount: childDiscountInput, childDiscount: childDiscountInput,
tags: tagsInput
});
notify({
title: 'Thành công',
message: 'Đã cập nhật thông tin chuyến đi.',
type: 'success'
}); });
notificationModal.openModal('Thành công', 'Đã cập nhật thông tin chuyến đi.', 'success');
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
} catch (error: any) { } catch (error: any) {
notificationModal.openModal('Lỗi', error.message || 'Không thể cập nhật thông tin.', 'error'); notify({ title: 'Lỗi', message: error.message || 'Không thể cập nhật thông tin.', type: 'error' });
} }
}; };
// Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính // Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính
@@ -414,12 +639,20 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors"> <button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<ChevronLeft className="w-6 h-6 text-gray-600" /> <ChevronLeft className="w-6 h-6 text-gray-600" />
</button> </button>
<h1 className="text-lg font-bold text-gray-800 truncate px-4"> <h1 className="text-lg font-bold text-gray-800 truncate px-4 flex-1 text-center">
{tourInfo.title} {tourInfo.title}
</h1> </h1>
<div className="w-10" /> {/* Spacer */} {/* Nút chia sẻ: Chỉ dành cho OWNER, MANAGER, MEMBER */}
{canShare && (
<button
onClick={handleShare}
className="p-2 hover:bg-blue-50 text-blue-600 rounded-full transition-colors"
title="Chia sẻ tour"
>
<Share2 className="w-5 h-5" />
</button>
)}
</div> </div>
{/* Tour Header Info */} {/* Tour Header Info */}
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end"> <div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
<img <img
@@ -433,6 +666,18 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
<div className="max-w-2xl mx-auto space-y-4"> <div className="max-w-2xl mx-auto space-y-4">
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2> <h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
{/* Nhãn hiển thị ngay dưới Tiêu đề */}
{currentTour?.tags && currentTour.tags.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{currentTour.tags.map((tag: string) => (
<span key={tag} className="px-2.5 py-1 bg-white/20 backdrop-blur-md border border-white/30 rounded-lg text-[10px] font-black uppercase tracking-wider">
{tag}
</span>
))}
</div>
)}
{/* Mô tả hiển thị dưới Nhãn */}
{currentTour?.description && ( {currentTour?.description && (
<p className="text-sm md:text-base text-white/90 max-w-xl line-clamp-3 md:line-clamp-none bg-black/20 backdrop-blur-sm p-4 rounded-2xl border border-white/10 italic leading-relaxed"> <p className="text-sm md:text-base text-white/90 max-w-xl line-clamp-3 md:line-clamp-none bg-black/20 backdrop-blur-sm p-4 rounded-2xl border border-white/10 italic leading-relaxed">
<Quote className="w-4 h-4 inline-block mr-2 opacity-50" /> <Quote className="w-4 h-4 inline-block mr-2 opacity-50" />
@@ -465,7 +710,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
{/* Member Avatars Stack */} {/* Member Avatars Stack */}
<div className="flex items-center gap-2 mt-4"> <div className="flex items-center gap-2 mt-4">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2"> {/* Always show participants */}
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => ( {currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
<button <button
key={p.userId || i} key={p.userId || i}
@@ -479,7 +724,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" /> <img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
</button> </button>
))} ))}
{isOwner && joinRequests.slice(0, 3).map((req: any) => ( {isOwner && !isPublicView && joinRequests.slice(0, 3).map((req: any) => ( // Hide join requests in public view
<div key={req.id} className="relative group"> <div key={req.id} className="relative group">
<div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg"> <div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg">
{req.user?.name?.charAt(0) || '?'} {req.user?.name?.charAt(0) || '?'}
@@ -491,23 +736,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
if (!currentTour) return; if (!currentTour) return;
setConfirmState({ const isConfirmed = await confirm({
open: true,
title: 'Chấp nhận yêu cầu', title: 'Chấp nhận yêu cầu',
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`, message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
onConfirm: async () => {
setJoinRequestActionId(req.id);
try {
await acceptJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
} finally {
setJoinRequestActionId(null);
setConfirmState({ open: false });
}
},
}); });
if (isConfirmed) {
setJoinRequestActionId(req.id);
try {
await acceptJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notify({ title: 'Thông báo', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
} finally {
setJoinRequestActionId(null);
}
}
}} }}
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50" className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
aria-label="Accept" aria-label="Accept"
@@ -520,23 +763,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
if (!currentTour) return; if (!currentTour) return;
setConfirmState({ const isConfirmed = await confirm({
open: true,
title: 'Từ chối yêu cầu', title: 'Từ chối yêu cầu',
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`, message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
onConfirm: async () => {
setJoinRequestActionId(req.id);
try {
await rejectJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
} finally {
setJoinRequestActionId(null);
setConfirmState({ open: false });
}
},
}); });
if (isConfirmed) {
setJoinRequestActionId(req.id);
try {
await rejectJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notify({ title: 'Thông báo', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
} finally {
setJoinRequestActionId(null);
}
}
}} }}
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1" className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
aria-label="Reject" aria-label="Reject"
@@ -552,18 +793,20 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
</div> </div>
)} )}
</div> </div>
<button {!isPublicView && ( // Hide add member button in public view
onClick={() => { <button
if (!currentTour) return; onClick={() => {
if (canInvite) setIsAddMemberOpen(true); if (!currentTour) return;
}} if (canInvite) setIsAddMemberOpen(true);
disabled={!canInvite} }}
className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${ disabled={!canInvite}
canInvite ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50' className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${
}`} canInvite ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'
> }`}
<Plus className="w-4 h-4" /> >
</button> <Plus className="w-4 h-4" />
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -571,7 +814,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
{/* Financial Quick-View Widget or Quote */} {/* Financial Quick-View Widget or Quote */}
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10"> <div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
<div <div // Always show quote if public view, otherwise show financial widget if has access
onClick={() => hasFinanceAccess && setActiveTab('expense')} onClick={() => hasFinanceAccess && setActiveTab('expense')}
className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200 cursor-pointer hover:scale-[1.02] active:scale-95' : 'bg-white text-gray-600 border border-gray-100'}`} className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200 cursor-pointer hover:scale-[1.02] active:scale-95' : 'bg-white text-gray-600 border border-gray-100'}`}
> >
@@ -584,7 +827,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
</div> </div>
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div> <div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
</> </>
) : ( ) : ( // If no finance access or is public view, show quote
<div className="flex items-start gap-4 py-2"> <div className="flex items-start gap-4 py-2">
<Quote className="w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" /> <Quote className="w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" />
<p className="italic text-lg font-medium leading-relaxed">"{randomQuote}"</p> <p className="italic text-lg font-medium leading-relaxed">"{randomQuote}"</p>
@@ -628,7 +871,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id as any)} onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${ className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
activeTab === tab.id activeTab === tab.id
? 'bg-blue-50 text-blue-600 shadow-sm' ? 'bg-blue-50 text-blue-600 shadow-sm'
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50' : 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
@@ -672,9 +915,56 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
setTargetLegId(loc.legId); setTargetLegId(loc.legId);
setMapCenter([loc.latitude, loc.longitude]); setMapCenter([loc.latitude, loc.longitude]);
setIsAddLocationOpen(true); setIsAddLocationOpen(true);
}} /> }} isPublicView={isPublicView} />
) : ( ) : (
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative"> <div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
{/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */}
{!isPublicView && (
<div className="absolute top-4 right-4 z-[1001] w-64 md:w-80">
<div className="relative group">
<input
type="text"
placeholder="Tìm địa điểm để ghim..."
className="w-full pl-10 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold"
value={searchQuery}
onChange={e => handleSearchLocation(e.target.value)}
/>
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
{isSearching ? (
<Loader2 className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
) : searchQuery && (
<button onClick={() => { setSearchQuery(''); setSearchResults([]); }} className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-red-500 transition-colors">
<X className="w-4 h-4" />
</button>
)}
{/* Kết quả tìm kiếm */}
{searchResults.length > 0 && (
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-48 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
{searchResults.map((result, idx) => (
<button
key={idx}
onClick={() => {
setMapCenter([parseFloat(result.lat), parseFloat(result.lon)]);
setSearchResults([]);
setSearchQuery('');
notify({
title: 'Tìm thấy địa điểm',
message: `Đã di chuyển bản đồ tới: ${result.namedetails?.name || result.display_name.split(',')[0]}`,
type: 'success'
});
}}
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-colors"
>
<div className="font-bold text-xs text-gray-900 truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
<div className="text-[10px] text-gray-500 truncate leading-tight">{result.display_name}</div>
</button>
))}
</div>
)}
</div>
</div>
)}
<MapContainer <MapContainer
center={initialViewState?.center || mapCenter} center={initialViewState?.center || mapCenter}
zoom={mapZoom} zoom={mapZoom}
@@ -682,7 +972,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
preferCanvas={true} preferCanvas={true}
> >
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
{canEdit && <MapContextMenu onAction={handleMapAction} />} {canEdit && !isPublicView && <MapContextMenu onAction={handleMapAction} />} {/* Hide map context menu in public view */}
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */} {/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
<MapTourBounds locations={allLocations} /> <MapTourBounds locations={allLocations} />
@@ -708,8 +998,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
return ( return (
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}> <Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}>
<Popup> <Popup>
<div className="font-bold">{loc.name}</div> <div className="p-1">
<div className="text-xs text-gray-500">{loc.type}</div> <div className="font-bold text-gray-900">{loc.name}</div>
<div className="text-[10px] text-gray-500 mb-2 uppercase tracking-tight">{loc.type}</div>
<button
onClick={() => {
setCommentLocationId(loc.id);
setCommentLocationName(loc.name);
setIsCommentModalOpen(true);
}}
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg text-[10px] font-black transition-all border border-blue-100"
>
<MessageSquare className="w-3 h-3" />
BÌNH LUẬN {loc._count?.comments > 0 && `(${loc._count.comments})`}
</button>
</div>
</Popup> </Popup>
</Marker> </Marker>
); );
@@ -717,7 +1020,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
</MarkerClusterGroup> </MarkerClusterGroup>
</MapContainer> </MapContainer>
<div className="absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white"> <div className="absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white">
Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải đ ghim đa điểm {isPublicView ? 'Xem chi tiết lộ trình' : 'Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải để ghim địa điểm'}
</div> </div>
</div> </div>
)} )}
@@ -731,21 +1034,107 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
)} )}
{activeTab === 'photo' && ( {activeTab === 'photo' && (
<div className="grid grid-cols-3 gap-1.5 animate-in fade-in"> <div className="animate-in fade-in">
{[1, 2, 3, 4, 5, 6].map((i) => ( {/* Main container for the new layout */}
<div key={i} className="aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white"> <div className="flex flex-col md:flex-row gap-4">
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" /> {/* Left Column: Leg List */}
<img <div className="md:w-1/4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex-shrink-0">
src={`https://picsum.photos/seed/${i + 10}/400/400`} <h3 className="text-sm font-bold text-gray-800 mb-3">Chặng của Tour</h3>
alt="Tour photo" <div className="space-y-2">
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" <button
/> onClick={() => {
setSelectedLegIdForPhoto('all');
setSelectedPhotoForDisplay(null); // Reset selected photo when changing leg
}}
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
selectedLegIdForPhoto === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Tất cả nh
</button>
{legs.map(leg => (
<button
key={leg.id}
onClick={() => {
setSelectedLegIdForPhoto(leg.id);
setSelectedPhotoForDisplay(null); // Reset selected photo when changing leg
}}
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
selectedLegIdForPhoto === leg.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Chặng {leg.sequence}: {leg.note || 'Không ghi chú'}
</button>
))}
</div>
</div> </div>
))}
{/* Right Column: Large Photo Display */}
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
{selectedPhotoForDisplay ? (
<div className="relative w-full h-full flex items-center justify-center">
<img
src={selectedPhotoForDisplay.imageUrl}
alt="Selected Tour Photo"
className="max-w-full max-h-[calc(100vh-200px)] object-contain rounded-xl shadow-md"
/>
{/* Optional: Add delete button for the large photo */}
{currentUserId === selectedPhotoForDisplay.uploaderId && !isPublicView && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeletePhoto(selectedPhotoForDisplay.id);
}}
className="absolute top-4 right-4 p-2 bg-red-500/80 backdrop-blur-sm text-white rounded-full shadow-lg hover:bg-red-600 transition-colors"
title="Xóa ảnh này"
>
<Trash2 className="w-5 h-5" />
</button>
)}
</div>
) : (
<div className="text-center text-gray-400">
<ImageIcon className="w-16 h-16 mx-auto mb-4" />
<p className="text-lg font-medium">Chọn một nh đ xem chi tiết</p>
</div>
)}
</div>
</div>
{/* Bottom Row: Thumbnails */}
<div className="mt-4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4">
<h3 className="text-sm font-bold text-gray-800 mb-3">
{selectedLegIdForPhoto === 'all' ? 'Tất cả ảnh' : `Ảnh của Chặng ${legs.find(l => l.id === selectedLegIdForPhoto)?.sequence || ''}`}
</h3>
{filteredPhotos.length > 0 ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 overflow-x-auto pb-2">
{filteredPhotos.map((photo: any) => (
<div
key={photo.id}
onClick={() => setSelectedPhotoForDisplay(photo)}
className={`aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border-2 ${
selectedPhotoForDisplay?.id === photo.id ? 'border-blue-500' : 'border-transparent'
} hover:border-blue-300 transition-all cursor-pointer`}
>
<img
src={photo.imageUrl}
alt="Thumbnail"
className="w-full h-full object-cover"
/>
</div>
))}
</div>
) : (
<div className="py-10 text-center text-gray-400">
<ImageIcon className="w-12 h-12 mx-auto mb-3" />
<p className="text-md font-medium">Chưa nh nào cho chặng này.</p>
</div>
)}
</div>
</div> </div>
)} )}
{activeTab === 'settings' && ( {activeTab === 'settings' && !isPublicView && ( // Hide settings tab in public view
<div className="space-y-4"> <div className="space-y-4">
<div className="p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95"> <div className="p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
@@ -772,23 +1161,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
disabled={joinRequestActionId === req.id} disabled={joinRequestActionId === req.id}
onClick={async () => { onClick={async () => {
if (!currentTour) return; if (!currentTour) return;
setConfirmState({ const isConfirmed = await confirm({
open: true,
title: 'Chấp nhận yêu cầu', title: 'Chấp nhận yêu cầu',
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`, message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
onConfirm: async () => {
setJoinRequestActionId(req.id);
try {
await acceptJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
} finally {
setJoinRequestActionId(null);
setConfirmState({ open: false });
}
},
}); });
if (isConfirmed) {
setJoinRequestActionId(req.id);
try {
await acceptJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notify({ title: 'Thông báo', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
} finally {
setJoinRequestActionId(null);
}
}
}} }}
className="p-2 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 disabled:opacity-50" className="p-2 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 disabled:opacity-50"
aria-label="Accept" aria-label="Accept"
@@ -799,23 +1186,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
disabled={joinRequestActionId === req.id} disabled={joinRequestActionId === req.id}
onClick={async () => { onClick={async () => {
if (!currentTour) return; if (!currentTour) return;
setConfirmState({ const isConfirmed = await confirm({
open: true,
title: 'Từ chối yêu cầu', title: 'Từ chối yêu cầu',
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`, message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
onConfirm: async () => {
setJoinRequestActionId(req.id);
try {
await rejectJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
} finally {
setJoinRequestActionId(null);
setConfirmState({ open: false });
}
},
}); });
if (isConfirmed) {
setJoinRequestActionId(req.id);
try {
await rejectJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notify({ title: 'Thông báo', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
} finally {
setJoinRequestActionId(null);
}
}
}} }}
className="p-2 rounded-xl bg-red-50 text-red-700 hover:bg-red-100 disabled:opacity-50" className="p-2 rounded-xl bg-red-50 text-red-700 hover:bg-red-100 disabled:opacity-50"
aria-label="Reject" aria-label="Reject"
@@ -903,6 +1288,46 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
/> />
</div> </div>
</div> </div>
<div>
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1 flex items-center gap-2">
<TagIcon className="w-3 h-3" /> Phân loại Tour
</label>
<div className="flex flex-wrap gap-2">
{availableTags.map(tag => (
<button
key={tag}
type="button"
onClick={() => setTagsInput(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all border ${
tagsInput.includes(tag)
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-500 border-gray-200 hover:border-blue-300'
}`}
>
{tag}
</button>
))}
</div>
<div className="flex gap-2 mt-3">
<input
type="text"
value={customTag}
onChange={(e) => setCustomTag(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), setTagsInput(prev => customTag.trim() && !prev.includes(customTag.trim()) ? [...prev, customTag.trim()] : prev), setCustomTag(''))}
placeholder="Thêm nhãn tùy chỉnh..."
className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={() => { if (customTag.trim() && !tagsInput.includes(customTag.trim())) { setTagsInput([...tagsInput, customTag.trim()]); setCustomTag(''); } }}
className="px-4 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all border border-blue-100"
>
Thêm
</button>
</div>
</div>
<button <button
onClick={handleUpdateTourInfo} onClick={handleUpdateTourInfo}
className="w-full mt-6 px-4 py-4 bg-blue-600 hover:bg-blue-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-blue-100 active:scale-95" className="w-full mt-6 px-4 py-4 bg-blue-600 hover:bg-blue-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-blue-100 active:scale-95"
@@ -923,13 +1348,14 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
</div> </div>
{/* Floating Action Button (Mobile) */} {/* Floating Action Button (Mobile) */}
{canEdit && ( {((activeTab === 'plan' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40"> <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
<button <button
onClick={() => { onClick={() => {
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
setEditingLocation(null); setEditingLocation(null);
if (activeTab === 'plan') setIsAddLocationOpen(true); if (activeTab === 'plan') setIsAddLocationOpen(true);
if (activeTab === 'photo' && canUploadPhoto) setIsAddPhotoOpen(true);
}} }}
className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold"> className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'} {activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
@@ -948,6 +1374,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
onRemoveMember={(userId) => removeMember(currentTour.id, userId)} onRemoveMember={(userId) => removeMember(currentTour.id, userId)}
onMemberAdded={() => fetchTour(currentTour.id)} onMemberAdded={() => fetchTour(currentTour.id)}
userRole={userRole || undefined} userRole={userRole || undefined}
isPublicView={isPublicView} // Pass isPublicView
/> />
)} )}
@@ -959,6 +1386,17 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
initialLegId={targetLegId || undefined} initialLegId={targetLegId || undefined}
editingLocation={editingLocation} editingLocation={editingLocation}
tourId={currentTour.id} tourId={currentTour.id}
isPublicView={isPublicView} // Pass isPublicView
/>
)}
{/* Add Photo Modal */}
{currentTour && (
<AddPhotoModal
isOpen={isAddPhotoOpen}
onClose={() => setIsAddPhotoOpen(false)}
tourId={currentTour.id}
onSuccess={() => fetchTour(currentTour.id)}
/> />
)} )}
@@ -993,7 +1431,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
await removeMember(currentTour.id, selectedMember.userId); await removeMember(currentTour.id, selectedMember.userId);
setIsMemberDetailOpen(false); setIsMemberDetailOpen(false);
} catch (e) { } catch (e) {
notificationModal.openModal('Thông báo', 'Không thể xóa thành viên', 'error'); notify({ title: 'Thông báo', message: 'Không thể xóa thành viên', type: 'error' });
} }
}} }}
className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold" className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold"
@@ -1016,19 +1454,14 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
</div> </div>
</div> </div>
)} )}
<ConfirmModal <CommentModal
isOpen={confirmState.open} isOpen={isCommentModalOpen}
title={confirmState.title} onClose={() => setIsCommentModalOpen(false)}
message={confirmState.message} locationId={commentLocationId}
onConfirm={() => confirmState.onConfirm?.()} locationName={commentLocationName}
onCancel={() => setConfirmState({ open: false })} isPublicView={isPublicView} // Pass isPublicView
/> onCommentAdded={() => handleCommentIncrement(commentLocationId)}
<NotificationModal onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
isOpen={notificationModal.modalState?.isOpen ?? false}
title={notificationModal.modalState?.title}
message={notificationModal.modalState?.message}
type={notificationModal.modalState?.type}
onConfirm={() => notificationModal.closeModal()}
/> />
</div> </div>
); );
+42 -44
View File
@@ -28,6 +28,7 @@ interface TourState {
setActiveLegId: (id: string | null) => void; setActiveLegId: (id: string | null) => void;
setMapCenter: (pos: [number, number]) => void; setMapCenter: (pos: [number, number]) => void;
fetchTour: (id: string) => Promise<void>; fetchTour: (id: string) => Promise<void>;
fetchPublicTourDetails: (tourId: string) => Promise<void>;
fetchPublicTours: () => Promise<void>; fetchPublicTours: () => Promise<void>;
createJoinRequest: (tourId: string, userId?: string) => Promise<any>; createJoinRequest: (tourId: string, userId?: string) => Promise<any>;
fetchJoinRequests: (tourId: string) => Promise<any[]>; fetchJoinRequests: (tourId: string) => Promise<any[]>;
@@ -47,10 +48,9 @@ export const useTourStore = create<TourState>((set, get) => ({
setActiveLegId: (id) => set({ activeLegId: id }), setActiveLegId: (id) => set({ activeLegId: id }),
setMapCenter: (pos) => set({ mapCenter: pos }), setMapCenter: (pos) => set({ mapCenter: pos }),
fetchTour: async (id: string) => { fetchTour: async (id: string) => {
const API_BASE = `http://${window.location.hostname}:3001`;
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
try { try {
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, { const response = await fetch(`/api/v1/tours/${id}`, {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -75,11 +75,10 @@ export const useTourStore = create<TourState>((set, get) => ({
} }
}, },
fetchPublicTours: async () => { fetchPublicTours: async () => {
const API_BASE = `http://${window.location.hostname}:3001`;
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) return; if (!token) return;
const response = await fetch(`${API_BASE}/api/v1/tours/explore`, { const response = await fetch(`/api/v1/tours/explore`, {
headers: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }
@@ -89,9 +88,27 @@ export const useTourStore = create<TourState>((set, get) => ({
const data = await response.json(); const data = await response.json();
set({ publicTours: data }); set({ publicTours: data });
}, },
fetchPublicTourDetails: async (tourId: string) => {
try {
// Reset state cũ trước khi tải dữ liệu mới
set({ currentTour: null, legs: [], userRole: 'VIEWER_ONLY' });
const response = await fetch(`/api/v1/tours/${tourId}/public`);
if (!response.ok) throw new Error('Không thể tải tour công khai');
const data = await response.json();
const legs = data.legs || [];
set({
currentTour: data,
legs,
userRole: 'VIEWER_ONLY',
activeLegId: legs.length > 0 ? legs[0].id : null
});
} catch (err: any) {
console.error('Lỗi khi tải tour công khai:', err);
}
},
createTour: async (tourData: any) => { createTour: async (tourData: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours`, {
const response = await fetch(`${API_BASE}/api/v1/tours`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -108,9 +125,8 @@ export const useTourStore = create<TourState>((set, get) => ({
return tour; return tour;
}, },
updateTourDetails: async (tourId: string, data: any) => { updateTourDetails: async (tourId: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`;
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}`, { const response = await fetch(`/api/v1/tours/${tourId}`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -125,8 +141,7 @@ export const useTourStore = create<TourState>((set, get) => ({
} }
}, },
updateTour: async (id: string, data: any) => { updateTour: async (id: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${id}`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -140,8 +155,7 @@ export const useTourStore = create<TourState>((set, get) => ({
get().fetchPublicTours(); get().fetchPublicTours();
}, },
deleteTour: async (id: string) => { deleteTour: async (id: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${id}`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}` 'Authorization': `Bearer ${localStorage.getItem('token')}`
@@ -157,8 +171,7 @@ export const useTourStore = create<TourState>((set, get) => ({
get().fetchPublicTours(); get().fetchPublicTours();
}, },
addLeg: async (tourId: string, data: any) => { addLeg: async (tourId: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/legs`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/legs`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -170,8 +183,7 @@ export const useTourStore = create<TourState>((set, get) => ({
get().fetchTour(tourId); get().fetchTour(tourId);
}, },
initializeLegs: async (tourId: string, count: number) => { initializeLegs: async (tourId: string, count: number) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/legs/batch`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/legs/batch`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -183,8 +195,7 @@ export const useTourStore = create<TourState>((set, get) => ({
await get().fetchTour(tourId); await get().fetchTour(tourId);
}, },
updateLeg: async (legId: string, data: any) => { updateLeg: async (legId: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/legs/${legId}`, {
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -198,8 +209,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
deleteLeg: async (legId: string) => { deleteLeg: async (legId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/legs/${legId}`, {
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}` 'Authorization': `Bearer ${localStorage.getItem('token')}`
@@ -214,8 +224,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
addLocation: async (tourId: string, locationData: any) => { addLocation: async (tourId: string, locationData: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/locations`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/locations`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -228,8 +237,7 @@ export const useTourStore = create<TourState>((set, get) => ({
get().fetchTour(tourId); get().fetchTour(tourId);
}, },
updateLocation: async (locationId: string, data: any) => { updateLocation: async (locationId: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/locations/${locationId}`, {
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -243,8 +251,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
deleteLocation: async (locationId: string) => { deleteLocation: async (locationId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/locations/${locationId}`, {
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}` 'Authorization': `Bearer ${localStorage.getItem('token')}`
@@ -256,8 +263,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
updateTourStartPoint: async (tourId: string, data: any) => { updateTourStartPoint: async (tourId: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/start-point`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/start-point`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -271,8 +277,7 @@ export const useTourStore = create<TourState>((set, get) => ({
await get().fetchTour(tourId); await get().fetchTour(tourId);
}, },
updateTourEndPoint: async (tourId: string, data: any) => { updateTourEndPoint: async (tourId: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/end-point`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/end-point`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -284,8 +289,7 @@ export const useTourStore = create<TourState>((set, get) => ({
await get().fetchTour(tourId); await get().fetchTour(tourId);
}, },
optimizeRouting: async (legId: string) => { optimizeRouting: async (legId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/routing/optimize/${legId}`, {
const response = await fetch(`${API_BASE}/api/v1/routing/optimize/${legId}`, {
method: 'POST' method: 'POST'
}); });
const { locations, totalDistance } = await response.json(); const { locations, totalDistance } = await response.json();
@@ -299,8 +303,7 @@ export const useTourStore = create<TourState>((set, get) => ({
} }
}, },
removeMember: async (tourId: string, userId: string) => { removeMember: async (tourId: string, userId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/members/${userId}`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members/${userId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`, Authorization: `Bearer ${localStorage.getItem('token')}`,
@@ -311,8 +314,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
addMember: async (tourId: string, member: { userId: string; role?: string }) => { addMember: async (tourId: string, member: { userId: string; role?: string }) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/members`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -328,8 +330,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
createJoinRequest: async (tourId: string, userId?: string) => { createJoinRequest: async (tourId: string, userId?: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -344,8 +345,7 @@ export const useTourStore = create<TourState>((set, get) => ({
return response.json(); return response.json();
}, },
fetchJoinRequests: async (tourId: string) => { fetchJoinRequests: async (tourId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests`, {
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('token')}` Authorization: `Bearer ${localStorage.getItem('token')}`
} }
@@ -357,8 +357,7 @@ export const useTourStore = create<TourState>((set, get) => ({
return response.json(); return response.json();
}, },
acceptJoinRequest: async (tourId: string, requestId: string) => { acceptJoinRequest: async (tourId: string, requestId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/join-requests/${requestId}/accept`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests/${requestId}/accept`, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('token')}` Authorization: `Bearer ${localStorage.getItem('token')}`
@@ -372,8 +371,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
rejectJoinRequest: async (tourId: string, requestId: string) => { rejectJoinRequest: async (tourId: string, requestId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/join-requests/${requestId}/reject`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests/${requestId}/reject`, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('token')}` Authorization: `Bearer ${localStorage.getItem('token')}`
+42 -16
View File
@@ -1,23 +1,49 @@
import { defineConfig } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
import fs from 'fs';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [react()], // Nạp các biến môi trường từ thư mục gốc
resolve: { const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
alias: {
'@': path.resolve(__dirname, './src'), // Cấu hình HTTPS nếu tìm thấy file chứng chỉ (ví dụ đặt tại thư mục gốc của dự án)
}, const httpsConfig = fs.existsSync('../key.pem') && fs.existsSync('../cert.pem')
}, ? {
server: { key: fs.readFileSync('../key.pem'),
port: 3002, cert: fs.readFileSync('../cert.pem'),
host: true, }
proxy: { : undefined;
'/api': {
target: 'http://127.0.0.1:3001', return {
changeOrigin: true, plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
}, },
}, },
}, server: {
port: 3002,
host: true,
// Cho phép các host từ file .env
allowedHosts: env.ALLOWED_HOSTS ? env.ALLOWED_HOSTS.split(',') : true,
https: httpsConfig,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3001',
ws: true,
changeOrigin: true,
},
},
},
};
}); });
+1758 -53
View File
File diff suppressed because it is too large Load Diff