Compare commits
10 Commits
29d39ae7b0
...
1eed9b00de
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eed9b00de | |||
| 1709638bfd | |||
| 37b5d14d7e | |||
| 2fabdb79df | |||
| 00554224a1 | |||
| 88b2182789 | |||
| 989d60643b | |||
| 1772ced959 | |||
| c005009da2 | |||
| bfd18e05dd |
@@ -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>>;
|
||||
}
|
||||
@@ -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"}
|
||||
Vendored
+14
@@ -1,6 +1,20 @@
|
||||
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;
|
||||
|
||||
Vendored
+355
-24
@@ -44,24 +44,45 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||
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 });
|
||||
exports.CommentGateway = void 0;
|
||||
exports.CommentGateway = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = void 0;
|
||||
const dotenv = __importStar(require("dotenv"));
|
||||
const path = __importStar(require("path"));
|
||||
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
require("reflect-metadata");
|
||||
const fs = __importStar(require("fs"));
|
||||
const zlib = __importStar(require("zlib"));
|
||||
const util_1 = require("util");
|
||||
const sharp_1 = __importDefault(require("sharp"));
|
||||
const core_1 = require("@nestjs/core");
|
||||
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 client_1 = require("@prisma/client");
|
||||
const bcrypt = __importStar(require("bcrypt"));
|
||||
const admin_guard_1 = require("./auth/admin.guard");
|
||||
const jwt_1 = require("@nestjs/jwt");
|
||||
const jwt_auth_guard_1 = require("./auth/jwt-auth.guard");
|
||||
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() {
|
||||
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);
|
||||
@@ -72,9 +93,112 @@ async function bootstrap() {
|
||||
const app = await core_1.NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableCors();
|
||||
if (!fs.existsSync(UPLOAD_ROOT)) {
|
||||
fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
|
||||
}
|
||||
app.useStaticAssets(UPLOAD_ROOT, {
|
||||
prefix: '/uploads/',
|
||||
});
|
||||
await app.listen(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 {
|
||||
getHello() {
|
||||
return 'Travel Planning API is running!';
|
||||
@@ -162,6 +286,7 @@ let PublicTourController = class PublicTourController {
|
||||
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: {
|
||||
@@ -190,12 +315,15 @@ let PublicTourController = class PublicTourController {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!tour)
|
||||
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),
|
||||
@@ -207,8 +335,9 @@ PublicTourController = __decorate([
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
], PublicTourController);
|
||||
let TourController = class TourController {
|
||||
constructor(prisma) {
|
||||
constructor(prisma, cacheManager) {
|
||||
this.prisma = prisma;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
async createTour(body, req) {
|
||||
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
|
||||
@@ -395,6 +524,43 @@ let TourController = class TourController {
|
||||
});
|
||||
}
|
||||
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({
|
||||
where: { id },
|
||||
});
|
||||
@@ -462,6 +628,7 @@ let TourController = class TourController {
|
||||
async addMember(tourId, body, req) {
|
||||
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'];
|
||||
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({
|
||||
where: { tourId_userId: { tourId, userId: body.userId } },
|
||||
});
|
||||
@@ -562,6 +729,7 @@ let TourController = class TourController {
|
||||
if (joinRequest.status !== 'PENDING') {
|
||||
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({
|
||||
where: { tourId_userId: { tourId, userId: joinRequest.userId } },
|
||||
});
|
||||
@@ -620,11 +788,48 @@ let TourController = class TourController {
|
||||
if (!participation) {
|
||||
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({
|
||||
where: { tourId_userId: { tourId, userId } },
|
||||
});
|
||||
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([
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
@@ -636,7 +841,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "createTour", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
@@ -646,7 +852,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "addLocation", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
@@ -656,7 +863,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "updateTourStartPoint", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
@@ -666,7 +874,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "updateTourEndPoint", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
@@ -675,7 +884,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "initializeLegs", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
@@ -684,7 +894,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "addLeg", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
@@ -693,7 +904,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "updateTour", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
__metadata("design:type", Function),
|
||||
@@ -701,6 +913,7 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "deleteTour", null);
|
||||
__decorate([
|
||||
(0, common_1.UseInterceptors)(compress_cache_interceptor_1.CompressCacheInterceptor),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
(0, common_1.Get)('explore'),
|
||||
__param(0, (0, common_1.Req)()),
|
||||
@@ -709,7 +922,9 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "getPublicTours", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
__metadata("design:type", Function),
|
||||
@@ -717,7 +932,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "getTourDetails", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
@@ -727,7 +943,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "addMember", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Req)()),
|
||||
@@ -736,7 +953,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "getJoinRequests", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
@@ -746,7 +964,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "createJoinRequest", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Param)('requestId')),
|
||||
@@ -756,7 +975,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "acceptJoinRequest", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Param)('requestId')),
|
||||
@@ -766,7 +986,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "rejectJoinRequest", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Param)('userId', common_1.ParseUUIDPipe)),
|
||||
@@ -774,9 +995,22 @@ __decorate([
|
||||
__metadata("design:paramtypes", [String, String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], 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([
|
||||
(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);
|
||||
let LocationController = class LocationController {
|
||||
constructor(prisma) {
|
||||
@@ -892,7 +1126,8 @@ __decorate([
|
||||
], LegController.prototype, "deleteLeg", null);
|
||||
LegController = __decorate([
|
||||
(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])
|
||||
], LegController);
|
||||
function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||
@@ -994,8 +1229,54 @@ __decorate([
|
||||
], RoutingController.prototype, "optimize", null);
|
||||
RoutingController = __decorate([
|
||||
(0, common_1.Controller)('routing'),
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
], 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 {
|
||||
constructor(prisma) {
|
||||
this.prisma = prisma;
|
||||
@@ -1015,6 +1296,15 @@ let UserController = class UserController {
|
||||
});
|
||||
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) {
|
||||
if (data.password) {
|
||||
data.passwordHash = await bcrypt.hash(data.password, 10);
|
||||
@@ -1035,8 +1325,23 @@ let UserController = class UserController {
|
||||
if (adminCount <= 1)
|
||||
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.user.delete({ where: { id } });
|
||||
if (fs.existsSync(memberDir)) {
|
||||
fs.rmSync(memberDir, { recursive: true, force: true });
|
||||
}
|
||||
return { message: 'Đã xóa người dùng' };
|
||||
}
|
||||
async toggleBlock(id) {
|
||||
@@ -1060,6 +1365,15 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], UserController.prototype, "getAllUsers", null);
|
||||
__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'),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
@@ -1068,6 +1382,7 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], UserController.prototype, "updateUser", null);
|
||||
__decorate([
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER),
|
||||
(0, common_1.Delete)(':id'),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
__metadata("design:type", Function),
|
||||
@@ -1075,6 +1390,7 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], UserController.prototype, "deleteUser", null);
|
||||
__decorate([
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
|
||||
(0, common_1.Post)('block/:id'),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
__metadata("design:type", Function),
|
||||
@@ -1083,6 +1399,7 @@ __decorate([
|
||||
], UserController.prototype, "toggleBlock", null);
|
||||
UserController = __decorate([
|
||||
(0, common_1.Controller)('users'),
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
], UserController);
|
||||
let CommentGateway = class CommentGateway {
|
||||
@@ -1155,7 +1472,8 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], CommentController.prototype, "getComments", null);
|
||||
__decorate([
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
(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)()),
|
||||
@@ -1174,15 +1492,28 @@ let AppModule = class AppModule {
|
||||
AppModule = __decorate([
|
||||
(0, common_1.Module)({
|
||||
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({
|
||||
secret: process.env.JWT_SECRET || 'super-secret',
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController],
|
||||
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, rbac_middleware_1.TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController],
|
||||
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector],
|
||||
exports: [prisma_service_1.PrismaService]
|
||||
})
|
||||
], AppModule);
|
||||
bootstrap();
|
||||
bootstrap().catch(err => {
|
||||
console.error('💥 Lỗi khởi động Server:');
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
//# sourceMappingURL=main.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -19,6 +19,7 @@
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^3.1.3",
|
||||
"@nestjs/common": "^11.1.27",
|
||||
"@nestjs/core": "^11.1.27",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
@@ -29,12 +30,16 @@
|
||||
"@prisma/adapter-pg": "^5.16.2",
|
||||
"@prisma/client": "^5.16.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.12.0",
|
||||
"redis": "^6.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sharp": "^0.35.1",
|
||||
"socket.io": "^4.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,15 +180,16 @@ model Expense {
|
||||
|
||||
model Photo {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
tourId String?
|
||||
locationId String?
|
||||
uploaderId String
|
||||
imageUrl String
|
||||
imageUrl String?
|
||||
originalUrl String?
|
||||
capturedAt DateTime @default(now())
|
||||
metadata Json?
|
||||
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)
|
||||
uploader User @relation(fields: [uploaderId], references: [id])
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
+391
-11
@@ -6,17 +6,45 @@ const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
import 'reflect-metadata';
|
||||
import * as fs from 'fs';
|
||||
import * as zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import sharp from 'sharp';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable } 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 { ParticipantRole } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AdminGuard } from './auth/admin.guard';
|
||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
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() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
@@ -28,15 +56,131 @@ async function bootstrap() {
|
||||
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||
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');
|
||||
// Bật CORS để cho phép Frontend kết nối API không bị chặn
|
||||
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);
|
||||
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()
|
||||
class AppController {
|
||||
@@ -107,8 +251,10 @@ class AuthController {
|
||||
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: {
|
||||
@@ -138,14 +284,20 @@ class PublicTourController {
|
||||
},
|
||||
});
|
||||
|
||||
if (!tour) throw new NotFoundException(`Không tìm thấy Tour`);
|
||||
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')
|
||||
class TourController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
@@ -183,6 +335,7 @@ class TourController {
|
||||
});
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER) // Allow members to add locations
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/locations')
|
||||
async addLocation(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
|
||||
@@ -225,6 +378,7 @@ class TourController {
|
||||
});
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can set start point
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/start-point')
|
||||
async updateTourStartPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
|
||||
@@ -262,6 +416,7 @@ class TourController {
|
||||
});
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can set end point
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/end-point')
|
||||
async updateTourEndPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
|
||||
@@ -297,6 +452,7 @@ class TourController {
|
||||
});
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can initialize legs
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/legs/batch')
|
||||
async initializeLegs(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { count: number }) {
|
||||
@@ -342,6 +498,7 @@ class TourController {
|
||||
return allLegs;
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add legs
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/legs')
|
||||
async addLeg(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any) {
|
||||
@@ -360,6 +517,7 @@ class TourController {
|
||||
});
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can update tour details
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Patch(':id')
|
||||
async updateTour(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
|
||||
@@ -379,16 +537,69 @@ class TourController {
|
||||
});
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER) // Only owner can delete tour
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Delete(':id')
|
||||
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({
|
||||
where: { id },
|
||||
});
|
||||
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)
|
||||
@Get('explore')
|
||||
async getPublicTours(@Req() req: any) {
|
||||
@@ -419,6 +630,8 @@ class TourController {
|
||||
});
|
||||
}
|
||||
|
||||
@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)
|
||||
@Get(':id')
|
||||
async getTourDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@@ -455,12 +668,16 @@ class TourController {
|
||||
return tour;
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add members
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/members')
|
||||
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 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({
|
||||
where: { tourId_userId: { tourId, userId: body.userId } },
|
||||
});
|
||||
@@ -505,6 +722,7 @@ class TourController {
|
||||
});
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can view join requests
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Get(':tourId/join-requests')
|
||||
async getJoinRequests(@Param('tourId', ParseUUIDPipe) tourId: string, @Req() req: any) {
|
||||
@@ -520,6 +738,7 @@ class TourController {
|
||||
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)
|
||||
@Post(':tourId/join-requests')
|
||||
async createJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId?: string }, @Req() req: any) {
|
||||
@@ -555,6 +774,7 @@ class TourController {
|
||||
return joinRequest;
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can accept join requests
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/join-requests/:requestId/accept')
|
||||
async acceptJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) {
|
||||
@@ -579,6 +799,8 @@ class TourController {
|
||||
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({
|
||||
where: { tourId_userId: { tourId, userId: joinRequest.userId } },
|
||||
});
|
||||
@@ -607,6 +829,7 @@ class TourController {
|
||||
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)
|
||||
@Post(':tourId/join-requests/:requestId/reject')
|
||||
async rejectJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) {
|
||||
@@ -639,6 +862,7 @@ class TourController {
|
||||
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)
|
||||
@Delete(':tourId/members/:userId')
|
||||
async removeMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('userId', ParseUUIDPipe) userId: string) {
|
||||
@@ -648,11 +872,67 @@ class TourController {
|
||||
if (!participation) {
|
||||
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({
|
||||
where: { tourId_userId: { tourId, userId } },
|
||||
});
|
||||
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')
|
||||
@@ -713,7 +993,8 @@ class LocationController {
|
||||
}
|
||||
|
||||
@Controller('legs')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Leg operations
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
class LegController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@@ -760,6 +1041,8 @@ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: numbe
|
||||
}
|
||||
|
||||
@Controller('routing')
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Routing operations
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
class RoutingController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@@ -872,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')
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for User management (Admin/Manager)
|
||||
class UserController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@@ -893,6 +1219,20 @@ class UserController {
|
||||
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')
|
||||
async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any) {
|
||||
if (data.password) {
|
||||
@@ -906,6 +1246,7 @@ class UserController {
|
||||
});
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER) // Only owner can delete user
|
||||
@Delete(':id')
|
||||
async deleteUser(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
@@ -914,11 +1255,36 @@ class UserController {
|
||||
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');
|
||||
}
|
||||
|
||||
// 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.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' };
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for blocking user (Admin/Manager)
|
||||
@Post('block/:id')
|
||||
async toggleBlock(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
@@ -971,7 +1337,8 @@ class CommentController {
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@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,
|
||||
@@ -1006,16 +1373,29 @@ class CommentController {
|
||||
|
||||
@Module({
|
||||
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({
|
||||
secret: process.env.JWT_SECRET || 'super-secret',
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}) as any,
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
class AppModule {}
|
||||
|
||||
|
||||
bootstrap();
|
||||
bootstrap().catch(err => {
|
||||
console.error('💥 Lỗi khởi động Server:');
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 378 KiB |
+20
-2
@@ -3,15 +3,17 @@ import { LandingPage } from './pages/LandingPage';
|
||||
import { ExploreMap } from './pages/ExploreMap';
|
||||
import { TourDetailPage } from './pages/TourDetailPage';
|
||||
import { SignupPage } from './pages/SignupPage';
|
||||
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 [user, setUser] = useState<any>(null);
|
||||
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup'>(viewTourId ? 'tourDetail' : 'landing');
|
||||
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos'>(viewTourId ? 'tourDetail' : 'landing');
|
||||
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
||||
|
||||
@@ -84,6 +86,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<ConfirmProvider>
|
||||
<NotificationProvider>
|
||||
{(() => {
|
||||
if (currentPage === 'tourDetail') {
|
||||
return (
|
||||
@@ -96,7 +99,21 @@ function App() {
|
||||
}
|
||||
|
||||
if (currentPage === 'explore') {
|
||||
return <ExploreMap onBack={handleBackFromTourDetail} onLogout={handleLogout} user={user} onViewTour={handleViewTour} />;
|
||||
return (
|
||||
<ExploreMap
|
||||
onBack={handleBackFromTourDetail}
|
||||
onLogout={handleLogout}
|
||||
user={user}
|
||||
onViewTour={handleViewTour}
|
||||
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPage === 'myPhotos') {
|
||||
return (
|
||||
<MyPhotosPage onBack={() => setCurrentPage('explore')} />
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPage === 'signup') {
|
||||
@@ -105,6 +122,7 @@ function App() {
|
||||
|
||||
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
|
||||
})()}
|
||||
</NotificationProvider>
|
||||
</ConfirmProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
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 L from 'leaflet';
|
||||
|
||||
@@ -85,6 +86,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
|
||||
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
||||
const { legs, addLocation, updateLocation, mapCenter, currentTour, userRole } = useTourStore();
|
||||
const 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
|
||||
useEffect(() => {
|
||||
@@ -222,7 +224,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
|
||||
const handleUseCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
alert("Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị GPS.");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -262,7 +264,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
break;
|
||||
default: errorMessage += err.message;
|
||||
}
|
||||
alert(errorMessage);
|
||||
notify({ title: 'Lỗi định vị', message: errorMessage, type: 'error' });
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true, // Ưu tiên dùng GPS thay vì Wifi/Cell tower
|
||||
@@ -294,7 +296,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
}
|
||||
onClose();
|
||||
} 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 {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -314,8 +316,61 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
</div>
|
||||
|
||||
{/* Mini Map Picker */}
|
||||
<div className="h-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group">
|
||||
<MapContainer center={currentCoords} zoom={13} className="h-full w-full">
|
||||
<div className="h-64 w-full rounded-3xl overflow-hidden mb-6 border border-gray-100 relative shadow-xl group">
|
||||
{/* 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" />
|
||||
<Marker position={currentCoords} />
|
||||
<MapPicker center={currentCoords} onPick={handlePickLocation} />
|
||||
@@ -337,57 +392,15 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="relative">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Tên địa điểm</label>
|
||||
<div className="relative group">
|
||||
<input
|
||||
required
|
||||
placeholder="Gõ để tìm kiếm đị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"
|
||||
value={formData.name}
|
||||
onChange={e => handleSearchLocation(e.target.value)}
|
||||
/>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
||||
{isSearching ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||
) : formData.name ? (
|
||||
<button type="button" onClick={() => { setFormData({...formData, name: ''}); setSearchResults([]); setHasNoResults(false); }} className="hover:text-red-500 transition-colors">
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
) : (
|
||||
<Search className="w-4 h-4 text-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(searchResults.length > 0 || hasNoResults) && (
|
||||
<div className="absolute z-[5000] left-0 right-0 mt-2 bg-white border border-gray-200 rounded-2xl shadow-2xl overflow-hidden max-h-64 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
|
||||
{hasNoResults ? (
|
||||
<div className="px-4 py-6 text-center text-gray-400 text-sm italic">
|
||||
Không tìm thấy địa điểm nào phù hợp...
|
||||
</div>
|
||||
) : (
|
||||
searchResults.map((result, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); selectSearchResult(result); }}
|
||||
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-all flex flex-col gap-0.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-bold text-sm text-gray-900 line-clamp-1">
|
||||
{result.namedetails?.name || result.display_name.split(',')[0]}
|
||||
</div>
|
||||
{result.type && (
|
||||
<span className="text-[9px] 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 line-clamp-2 leading-tight">{result.display_name}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
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>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Địa chỉ</label>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect, useMemo } from '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 {
|
||||
isOpen: boolean;
|
||||
@@ -22,10 +24,11 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [fetchError, setFetchError] = 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 confirm = useConfirm();
|
||||
const notify = useNotification();
|
||||
|
||||
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 visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
||||
@@ -66,19 +69,17 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
|
||||
const handleRemove = async (userId: string, memberName: string) => {
|
||||
if (!onRemoveMember) return;
|
||||
setConfirmTarget({ userId, name: memberName });
|
||||
setIsConfirmOpen(true);
|
||||
};
|
||||
const isConfirmed = await confirm({
|
||||
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 (!confirmTarget || !onRemoveMember) return;
|
||||
try {
|
||||
await onRemoveMember(confirmTarget.userId);
|
||||
} catch (err: any) {
|
||||
setSubmitError(err.message || 'Không thể xóa thành viên');
|
||||
} finally {
|
||||
setIsConfirmOpen(false);
|
||||
setConfirmTarget(null);
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
await onRemoveMember(userId);
|
||||
} catch (err: any) {
|
||||
setSubmitError(err.message || 'Không thể xóa thành viên');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +100,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
}
|
||||
await onMemberAdded();
|
||||
} 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 {
|
||||
setActionLoading(null);
|
||||
}
|
||||
@@ -319,26 +320,6 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
</button>
|
||||
</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 có 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,8 @@ import React, { useState, useEffect } from 'react';
|
||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare } from 'lucide-react';
|
||||
import { 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 }) => {
|
||||
@@ -58,7 +59,8 @@ export const ItineraryTimeline = ({
|
||||
// Khai báo logic canEdit để sử dụng trong toàn bộ component
|
||||
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||
|
||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
||||
const confirm = useConfirm();
|
||||
const notify = useNotification();
|
||||
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
|
||||
const [tempLegCount, setTempLegCount] = useState(3);
|
||||
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
||||
@@ -151,35 +153,31 @@ export const ItineraryTimeline = ({
|
||||
};
|
||||
|
||||
const handleDeleteLeg = async (legId: string) => {
|
||||
setConfirmState({
|
||||
open: true,
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Xóa chặng',
|
||||
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 });
|
||||
}
|
||||
},
|
||||
message: 'Bạn có chắc chắn muốn xóa chặng này?'
|
||||
});
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
await deleteLeg(legId);
|
||||
} catch (err: any) {
|
||||
notify({ title: 'Lỗi', message: err.message, type: 'error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLocation = async (id: string) => {
|
||||
setConfirmState({
|
||||
open: true,
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Xóa địa điểm',
|
||||
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 });
|
||||
}
|
||||
},
|
||||
message: 'Bạn có chắc chắn muốn xóa địa điểm này?'
|
||||
});
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
await deleteLocation(id);
|
||||
} catch (err: any) {
|
||||
notify({ title: 'Lỗi', message: err.message, type: 'error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -454,13 +452,6 @@ export const ItineraryTimeline = ({
|
||||
</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) */}
|
||||
{isLegCountModalOpen && (
|
||||
|
||||
@@ -1,212 +1,60 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
userName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
interface ConfirmOptions {
|
||||
title?: string;
|
||||
message?: 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
|
||||
}
|
||||
const ConfirmContext = createContext<((options: ConfirmOptions) => Promise<boolean>) | undefined>(undefined);
|
||||
|
||||
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();
|
||||
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 userRole = useTourStore(state => state.userRole);
|
||||
const currentUserId = React.useMemo(() => {
|
||||
try {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
return user.id;
|
||||
} catch { return null; }
|
||||
const confirm = useCallback((options: ConfirmOptions) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setState({
|
||||
isOpen: true,
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
resolve,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}
|
||||
const handleConfirm = () => {
|
||||
const resolve = state.resolve;
|
||||
setState({ isOpen: false, resolve: undefined });
|
||||
resolve?.(true);
|
||||
};
|
||||
|
||||
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 handleCancel = () => {
|
||||
const resolve = state.resolve;
|
||||
setState({ isOpen: false, resolve: undefined });
|
||||
resolve?.(false);
|
||||
};
|
||||
|
||||
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 có 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={async () => {
|
||||
const isConfirmed = await confirm({
|
||||
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.'
|
||||
});
|
||||
if (isConfirmed) handleDelete(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>
|
||||
</div>
|
||||
<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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -5,9 +5,9 @@ const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerCluste
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon } 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 { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { CreateTourModal } from '../components/CreateTourModal';
|
||||
|
||||
// Fix lỗi icon mặc định của Leaflet
|
||||
@@ -31,7 +31,7 @@ function RecenterMap({ position }: { position: [number, number] }) {
|
||||
// Component Helper để đóng menu khi tương tác với bản đồ
|
||||
function MapEvents({ onMapAction }: { onMapAction: () => void }) {
|
||||
useMapEvents({
|
||||
click: onMapAction,
|
||||
click: () => onMapAction(),
|
||||
movestart: onMapAction,
|
||||
dragstart: onMapAction,
|
||||
});
|
||||
@@ -56,14 +56,14 @@ function MapTracker() {
|
||||
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
|
||||
const publicTours = useTourStore(state => state.publicTours);
|
||||
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||
const fetchTour = useTourStore(state => state.fetchTour);
|
||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||
|
||||
const notificationModal = useNotificationModal();
|
||||
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)
|
||||
const [initialViewState] = useState(() => {
|
||||
@@ -78,6 +78,48 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||
const [isAdminModalOpen, setIsAdminModalOpen] = 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'];
|
||||
@@ -104,7 +146,11 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
}).catch(() => {});
|
||||
} else if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
notificationModal.openModal('Thành công', 'Đã 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!', 'success');
|
||||
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
|
||||
@@ -114,13 +160,34 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
notificationModal.openModal('Thành công', 'Đã 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!', 'success');
|
||||
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));
|
||||
@@ -150,79 +217,150 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full relative">
|
||||
{/* Nút quay lại */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all"
|
||||
>
|
||||
<X className="w-6 h-6 text-gray-800" />
|
||||
</button>
|
||||
{/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */}
|
||||
<div className="absolute top-4 left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none">
|
||||
{/* Nhóm bên trái: Quay lại và Thông tin vị trí */}
|
||||
<div className="flex items-center gap-3 pointer-events-auto">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100"
|
||||
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 */}
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
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"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">Đăng xuất</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút quản lý người dùng cho Admin */}
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsAdminModalOpen(true)}
|
||||
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"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">Quản lý hệ thống</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút tạo Tour mới */}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
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"
|
||||
>
|
||||
<Navigation className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">Tạo Tour mới</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Header Overlay */}
|
||||
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block">
|
||||
<div className="flex items-center gap-2">
|
||||
<Navigation className="w-4 h-4 text-blue-600" />
|
||||
<span className="font-bold text-gray-800">Đang khám phá khu vực của bạn</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bộ lọc theo Tag */}
|
||||
<div className="absolute top-24 left-6 z-[1000] flex flex-col gap-2 pointer-events-none">
|
||||
<div className="bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white/20 pointer-events-auto flex flex-col gap-2 max-w-[200px]">
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{/* Nút lọc Tag và Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setSelectedFilterTag(null)}
|
||||
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'}`}
|
||||
onClick={() => setIsFilterDropdownOpen(prev => !prev)}
|
||||
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center"
|
||||
title="Lọc theo loại"
|
||||
>
|
||||
Tất cả
|
||||
<Filter className="w-6 h-6 text-gray-800" />
|
||||
</button>
|
||||
{allFilterTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => setSelectedFilterTag(tag === selectedFilterTag ? null : tag)}
|
||||
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>
|
||||
))}
|
||||
|
||||
{/* Filter Dropdown Content */}
|
||||
{isFilterDropdownOpen && (
|
||||
<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">
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5"> {/* Hiển thị tag theo chiều dọc */}
|
||||
<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>
|
||||
|
||||
{/* Search Box - Thay thế div "Khám phá khu vực" */}
|
||||
<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">
|
||||
<Navigation className="w-4 h-4 text-blue-600 mr-2 flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tìm kiếm địa điểm, tour..."
|
||||
className="flex-1 bg-transparent outline-none text-gray-800 text-sm font-medium"
|
||||
value={searchQuery}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Dropdown danh sách gợi ý */}
|
||||
{suggestions.length > 0 && (
|
||||
<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">
|
||||
{suggestions.map((s, idx) => (
|
||||
<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>
|
||||
|
||||
@@ -239,9 +377,8 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
|
||||
{/* Theo dõi di chuyển bản đồ */}
|
||||
<MapTracker />
|
||||
|
||||
{/* Đóng menu khi tương tác bản đồ */}
|
||||
<MapEvents onMapAction={() => setShareMenu(null)} />
|
||||
{/* Đó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 độ */}
|
||||
<RecenterMap position={userPos} />
|
||||
@@ -346,14 +483,6 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
onViewTour(tour.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
<NotificationModal
|
||||
isOpen={notificationModal.modalState?.isOpen ?? false}
|
||||
title={notificationModal.modalState?.title}
|
||||
message={notificationModal.modalState?.message}
|
||||
type={notificationModal.modalState?.type}
|
||||
onConfirm={() => notificationModal.closeModal()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
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 [filterTourId, setFilterTourId] = useState<string>('');
|
||||
const [filterDate, setFilterDate] = useState<string>('');
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null); // State cho lightbox
|
||||
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 uniqueTours = 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 sortedPhotos = photos.filter(p => {
|
||||
const matchTour = !filterTourId || p.tourId === filterTourId;
|
||||
// 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;
|
||||
});
|
||||
|
||||
// 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, filterTourId, filterDate, sortOrder]);
|
||||
|
||||
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));
|
||||
setSelectedPhoto(null); // Đóng lightbox
|
||||
} catch (error) {
|
||||
notify({ title: 'Lỗi', message: 'Không thể xóa ảnh. Vui lòng thử lại.', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// Đóng lightbox khi nhấn ESC
|
||||
useEffect(() => {
|
||||
const handleEsc = (event: KeyboardEvent) => event.key === 'Escape' && setSelectedPhoto(null);
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, []);
|
||||
|
||||
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 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>
|
||||
|
||||
{/* Lọc theo Tour */}
|
||||
<div className="relative min-w-[160px]">
|
||||
<select
|
||||
value={filterTourId}
|
||||
onChange={(e) => setFilterTourId(e.target.value)}
|
||||
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="">Tất cả chuyến đi</option>
|
||||
{uniqueTours.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.title}</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>
|
||||
|
||||
{/* 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">Cũ 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 */}
|
||||
{(filterTourId || filterDate) && (
|
||||
<button
|
||||
onClick={() => { setFilterTourId(''); 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>
|
||||
) : filteredPhotos.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filteredPhotos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
onClick={() => setSelectedPhoto(photo)}
|
||||
className="group relative bg-white rounded-3xl overflow-hidden shadow-sm border border-gray-100 transition-all hover:shadow-xl hover:-translate-y-1 cursor-pointer"
|
||||
>
|
||||
{/* Image Preview */}
|
||||
<div className="aspect-square relative overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={photo.imageUrl || photo.originalUrl}
|
||||
alt="My memory"
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
{!photo.imageUrl && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<span className="text-[10px] font-black text-white uppercase bg-red-500 px-2 py-1 rounded-lg">Tour đã xóa</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay Actions */}
|
||||
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
{photo.originalUrl && (
|
||||
<a
|
||||
onClick={(e) => e.stopPropagation()} // Ngăn chặn mở lightbox khi bấm tải xuống
|
||||
href={photo.originalUrl}
|
||||
download
|
||||
className="p-3 bg-white text-blue-600 rounded-2xl shadow-xl hover:bg-blue-600 hover:text-white transition-all transform hover:scale-110"
|
||||
title="Tải xuống ảnh gốc"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-1.5 mb-1 text-gray-400">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span className="text-[10px] font-bold truncate">
|
||||
{photo.tour?.title || 'Không rõ hành trình'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-gray-300">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span className="text-[9px] font-medium italic">
|
||||
{new Date(photo.capturedAt).toLocaleDateString('vi-VN')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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 có ảnh nào</h3>
|
||||
<p className="text-sm text-gray-300">Hãy tham gia các chuyến đi và lưu lại khoảnh khắc nhé!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightbox - Xem ảnh toàn màn hình */}
|
||||
{selectedPhoto && (
|
||||
<div
|
||||
className="fixed inset-0 z-[7000] flex items-center justify-center bg-black/95 backdrop-blur-md p-4 animate-in fade-in duration-300"
|
||||
onClick={() => setSelectedPhoto(null)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedPhoto(null)}
|
||||
className="absolute top-6 right-6 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-all z-10"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* Nút xóa ảnh */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(selectedPhoto.id); }}
|
||||
className="absolute top-6 left-6 p-3 bg-red-500/10 hover:bg-red-500/20 text-white rounded-full transition-all z-10"
|
||||
>
|
||||
<Trash2 className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div className="relative max-w-5xl w-full max-h-[90vh] flex flex-col items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<img
|
||||
src={selectedPhoto.originalUrl || selectedPhoto.imageUrl}
|
||||
alt="Fullscreen view"
|
||||
className="max-w-full max-h-[75vh] object-contain rounded-2xl shadow-2xl animate-in zoom-in-95 duration-300"
|
||||
/>
|
||||
|
||||
<div className="mt-6 text-center text-white">
|
||||
<h2 className="text-xl font-bold">{selectedPhoto.tour?.title || 'Không rõ hành trình'}</h2>
|
||||
<p className="text-sm opacity-60 italic mt-1">{new Date(selectedPhoto.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</p>
|
||||
|
||||
{selectedPhoto.originalUrl && (
|
||||
<a
|
||||
href={selectedPhoto.originalUrl}
|
||||
download
|
||||
className="mt-6 inline-flex items-center gap-2 px-8 py-3.5 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-black uppercase tracking-widest text-xs 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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,9 +5,10 @@ import { ExpenseManager } from '../components/ExpenseManager';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { AddLocationModal } from '@/components/AddLocationModal';
|
||||
import { AddMemberModal } from '../components/AddMemberModal';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
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 _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Map as MapIcon,
|
||||
Wallet,
|
||||
Image as ImageIcon,
|
||||
Upload,
|
||||
Calendar,
|
||||
Users,
|
||||
ChevronLeft,
|
||||
@@ -24,13 +26,16 @@ import {
|
||||
List,
|
||||
Map as MapIconLucide,
|
||||
MapPin,
|
||||
Search,
|
||||
Loader2,
|
||||
Flag,
|
||||
Clock,
|
||||
Check,
|
||||
X,
|
||||
MessageSquare,
|
||||
Share2,
|
||||
Tag as TagIcon
|
||||
Tag as TagIcon,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
|
||||
@@ -178,10 +183,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
||||
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
||||
const [targetLegId, setTargetLegId] = useState<string | null>(null);
|
||||
const [editingLocation, setEditingLocation] = useState<any>(null);
|
||||
const [selectedMember, setSelectedMember] = useState<any>(null);
|
||||
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 [titleInput, setTitleInput] = useState(currentTour?.title ?? '');
|
||||
const [descriptionInput, setDescriptionInput] = useState(currentTour?.description ?? '');
|
||||
@@ -192,12 +208,87 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
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 [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 fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action
|
||||
@@ -248,7 +339,8 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest);
|
||||
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
|
||||
const [initialViewState] = useState(() => {
|
||||
@@ -263,6 +355,7 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
// Nếu là public view, không có quyền chỉnh sửa
|
||||
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||
const canInvite = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||
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 || '');
|
||||
|
||||
@@ -275,6 +368,28 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
// 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);
|
||||
|
||||
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
|
||||
@@ -304,7 +419,11 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
}).catch(() => {});
|
||||
} else if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chia sẻ chuyến đi!', 'success');
|
||||
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)
|
||||
@@ -314,7 +433,11 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chia sẻ chuyến đi!', 'success');
|
||||
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);
|
||||
}
|
||||
@@ -450,10 +573,14 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
childDiscount: childDiscountInput,
|
||||
tags: tagsInput
|
||||
});
|
||||
notificationModal.openModal('Thành công', 'Đã cập nhật thông tin chuyến đi.', 'success');
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã cập nhật thông tin chuyến đi.',
|
||||
type: 'success'
|
||||
});
|
||||
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
|
||||
} 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
|
||||
@@ -609,23 +736,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!currentTour) return;
|
||||
setConfirmState({
|
||||
open: true,
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Chấp nhận yêu cầu',
|
||||
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 });
|
||||
}
|
||||
},
|
||||
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
|
||||
});
|
||||
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"
|
||||
aria-label="Accept"
|
||||
@@ -638,23 +763,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!currentTour) return;
|
||||
setConfirmState({
|
||||
open: true,
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Từ chối yêu cầu',
|
||||
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 });
|
||||
}
|
||||
},
|
||||
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
|
||||
});
|
||||
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"
|
||||
aria-label="Reject"
|
||||
@@ -795,6 +918,53 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
}} isPublicView={isPublicView} />
|
||||
) : (
|
||||
<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
|
||||
center={initialViewState?.center || mapCenter}
|
||||
zoom={mapZoom}
|
||||
@@ -864,17 +1034,103 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
)}
|
||||
|
||||
{activeTab === 'photo' && (
|
||||
<div className="grid grid-cols-3 gap-1.5 animate-in fade-in">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white">
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
<img
|
||||
src={`https://picsum.photos/seed/${i + 10}/400/400`}
|
||||
alt="Tour photo"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
<div className="animate-in fade-in">
|
||||
{/* Main container for the new layout */}
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Left Column: Leg 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">Chặng của Tour</h3>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
))}
|
||||
|
||||
{/* 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 có ảnh nào cho chặng này.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -905,23 +1161,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
disabled={joinRequestActionId === req.id}
|
||||
onClick={async () => {
|
||||
if (!currentTour) return;
|
||||
setConfirmState({
|
||||
open: true,
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Chấp nhận yêu cầu',
|
||||
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 });
|
||||
}
|
||||
},
|
||||
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
|
||||
});
|
||||
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"
|
||||
aria-label="Accept"
|
||||
@@ -932,23 +1186,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
disabled={joinRequestActionId === req.id}
|
||||
onClick={async () => {
|
||||
if (!currentTour) return;
|
||||
setConfirmState({
|
||||
open: true,
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Từ chối yêu cầu',
|
||||
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 });
|
||||
}
|
||||
},
|
||||
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
|
||||
});
|
||||
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"
|
||||
aria-label="Reject"
|
||||
@@ -1096,13 +1348,14 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
</div>
|
||||
|
||||
{/* Floating Action Button (Mobile) */}
|
||||
{canEdit && !isPublicView && ( // Hide floating action button in public view
|
||||
{((activeTab === 'plan' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
|
||||
setEditingLocation(null);
|
||||
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">
|
||||
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
|
||||
@@ -1137,6 +1390,16 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Photo Modal */}
|
||||
{currentTour && (
|
||||
<AddPhotoModal
|
||||
isOpen={isAddPhotoOpen}
|
||||
onClose={() => setIsAddPhotoOpen(false)}
|
||||
tourId={currentTour.id}
|
||||
onSuccess={() => fetchTour(currentTour.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Member Detail Popover */}
|
||||
{isMemberDetailOpen && selectedMember && (
|
||||
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
|
||||
@@ -1168,7 +1431,7 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
await removeMember(currentTour.id, selectedMember.userId);
|
||||
setIsMemberDetailOpen(false);
|
||||
} 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"
|
||||
@@ -1191,20 +1454,6 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmModal
|
||||
isOpen={confirmState.open}
|
||||
title={confirmState.title}
|
||||
message={confirmState.message}
|
||||
onConfirm={() => confirmState.onConfirm?.()}
|
||||
onCancel={() => setConfirmState({ open: false })}
|
||||
/>
|
||||
<NotificationModal
|
||||
isOpen={notificationModal.modalState?.isOpen ?? false}
|
||||
title={notificationModal.modalState?.title}
|
||||
message={notificationModal.modalState?.message}
|
||||
type={notificationModal.modalState?.type}
|
||||
onConfirm={() => notificationModal.closeModal()}
|
||||
/>
|
||||
<CommentModal
|
||||
isOpen={isCommentModalOpen}
|
||||
onClose={() => setIsCommentModalOpen(false)}
|
||||
|
||||
@@ -34,6 +34,10 @@ export default defineConfig(({ mode }) => {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3001',
|
||||
ws: true,
|
||||
|
||||
Generated
+875
-1
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user