16 Commits

Author SHA1 Message Date
3dtours a65be48d36 fix: lướt xem ảnh ở màn hình index của người dùng public 2026-06-22 22:16:51 +07:00
3dtours ac91f6e4c8 fix: admin login khi close tự động logout 2026-06-22 20:22:13 +07:00
3dtours 09b1ee882d fix: login với Google Oauth lỗi token invite 2026-06-22 20:08:11 +07:00
3dtours acf867a375 fix: guest hiển thị lời mời này không tồn tại 2026-06-22 16:54:32 +07:00
3dtours dd2635a44d fix: guest reload page to accessed MemberDashboard 2 2026-06-22 15:00:42 +07:00
3dtours 29c19c6372 fix: guest reload page to accessed MemberDashboard 2026-06-22 12:45:29 +07:00
3dtours 01d6d7439f fix: admin empty thùng rác 2026-06-22 12:28:21 +07:00
3dtours 860395cb14 fix: guest and admin logic 2026-06-22 11:41:34 +07:00
3dtours b1a539235b fix: lỗi hiển thị ở frontend 2026-06-21 21:53:14 +07:00
3dtours 403c169ddd feat: tính năng chia sẻ khẩn cấp 2026-06-21 12:52:47 +07:00
3dtours 8d79cd76f6 feat: tíng năng trò chuyện giữa các thành viên và tin nhắn trực tiếp cho bạn bè 2026-06-21 09:46:37 +07:00
3dtours 392a4d4766 fix: khách mời join vào tour gán với tên thêm thủ công 2026-06-21 07:37:20 +07:00
3dtours 55fba75fda fix: khách mời join vào tour thông qua link mời 2026-06-21 07:11:26 +07:00
3dtours 36157bd53b bugs: lỗi khi người dùng link để tham gia tour nhưng không xuất hiện trong danh sách thành viên 2026-06-20 20:59:01 +07:00
3dtours 52706cab7d fix: thêm thành viên ngoài hệ thống vào Tour 2026-06-20 17:56:56 +07:00
3dtours ae97f061e8 fix: sửa lỗi hiển thị logo trên trang pdf 2026-06-20 17:02:26 +07:00
115 changed files with 17244 additions and 1510 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules/
server/node_modules/
# Bỏ qua cấu hình hệ thống và Git
.git/
.idea/
.vscode/
# Bỏ qua các file log và build
*.log
dist/
build/
out/
+79
View File
@@ -0,0 +1,79 @@
# To AI Agent: Implement Mobile Horizontal Image Panning Component
## 1. Context & Objective
We are developing a travel application. We need to implement a mobile-first image viewer component.
**CRITICAL REQUIREMENT:** When a user swipes/drags horizontally on mobile, the UI must NOT switch to the next image. Instead, it must smoothly scroll/pan horizontally to reveal the hidden, unexposed parts of the *same* wide/panoramic image.
---
## 2. Technical Stack & Scope
- **Target Platform:** Mobile Web / Responsive (Touch-friendly).
- **Preferred Method:** CSS-First approach utilizing Viewport Overflow (for optimal GPU performance and native inertia scrolling).
- **Avoid:** Do NOT use global slider libraries (like standard Swiper/Slick) if they force image switching behavior.
---
## 3. UI/UX Specifications
### A. DOM Structure
- A wrapper/container acting as the "window view" (`.image-pan-container`).
- The target wide/panoramic image (`.image-pan-element`).
### B. CSS Rules & Constraints
1. **Container (`.image-pan-container`):**
- Must have a fixed width (e.g., `100vw` or `100%` of parent).
- Must set `overflow-x: auto` and `overflow-y: hidden` to enable horizontal touch scrolling only.
- Must enable smooth scrolling (`scroll-behavior: smooth`) and native touch momentum (`-webkit-overflow-scrolling: touch`).
- **Crucial:** Hide the native scrollbar across all major browsers (Webkit, Firefox, IE/Edge) to make it look like a native mobile app feature.
2. **Image Element (`.image-pan-element`):**
- Must fit the container's height perfectly (`height: 100%`).
- Width must be calculated automatically based on aspect ratio (`width: auto`).
- Must override any global framework styles: enforce `max-width: none !important`.
- Do NOT use `object-fit: cover` as it will crop the scrolling data.
---
## 4. Reference Code Blueprint
Use the following snippet as a baseline for your implementation:
```html
<div class="image-pan-container">
<img src="YOUR_PANORAMIC_IMAGE_URL" class="image-pan-element" alt="Panoramic View" />
</div>
/* Styling Architecture */
.image-pan-container {
width: 100%;
height: 400px; /* Adjust height based on project guidelines */
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
/* Hide scrollbars entirely */
.image-pan-container::-webkit-scrollbar {
display: none;
}
.image-pan-container {
-ms-overflow-style: none;
scrollbar-width: none;
}
.image-pan-element {
height: 100%;
width: auto;
max-width: none !important;
display: block;
}
5. Acceptance Criteria
[ ] The wide photo fills the component height and overflows horizontally without distortion.
[ ] Users can smoothly swipe left/right with their fingers to view all details of the photo.
[ ] No desktop/mobile scrollbars are visible during the interaction.
[ ] Ensure max-width override is active so Tailwind or other CSS frameworks don't crush the image width to 100%.
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
.env
.git
.gitignore
+38
View File
@@ -0,0 +1,38 @@
FROM node:20-alpine AS base
# Install openssl for Prisma
RUN apk add --no-cache openssl
WORKDIR /usr/src/app
COPY package*.json ./
COPY prisma ./prisma/
# Development stage
FROM base AS development
RUN npm install
COPY . .
RUN npx prisma generate
EXPOSE 3001
CMD ["npm", "run", "start:dev"]
# Build stage for production
FROM base AS build
RUN npm install
COPY . .
RUN npx prisma generate
RUN npm run build
RUN npm prune --production
# Production stage
FROM node:20-alpine AS production
RUN apk add --no-cache openssl
WORKDIR /usr/src/app
COPY package*.json ./
COPY --from=build /usr/src/app/node_modules ./node_modules
COPY --from=build /usr/src/app/dist ./dist
COPY --from=build /usr/src/app/prisma ./prisma
EXPOSE 3001
CMD ["node", "dist/src/main.js"]
+39
View File
@@ -0,0 +1,39 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5';
const ownerUserId = '5b2053bb-f523-4a11-817c-f47fef7322bb'; // owner@travel.com
// Check if owner@travel.com is already a participant
const existing = await prisma.tourParticipant.findFirst({
where: { tourId, userId: ownerUserId }
});
if (existing) {
await prisma.tourParticipant.update({
where: { id: existing.id },
data: { role: 'OWNER' }
});
console.log('Updated existing participant to OWNER');
} else {
// Demote current owner to MEMBER or just keep them
const result = await prisma.tourParticipant.create({
data: {
tourId,
userId: ownerUserId,
role: 'OWNER'
}
});
console.log('Created new OWNER participant:', result);
}
// Update tour createdById to ownerUserId
await prisma.tour.update({
where: { id: tourId },
data: { createdById: ownerUserId }
});
console.log('Updated tour creator to owner@travel.com');
}
main().catch(console.error).finally(() => prisma.$disconnect());
+4
View File
@@ -1,4 +1,8 @@
declare const JwtAuthGuard_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
export declare class JwtAuthGuard extends JwtAuthGuard_base {
}
declare const JwtAuthGuardNoAnonymous_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
export declare class JwtAuthGuardNoAnonymous extends JwtAuthGuardNoAnonymous_base {
handleRequest(err: any, user: any, info: any): any;
}
export {};
+17 -1
View File
@@ -6,13 +6,29 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.JwtAuthGuard = void 0;
exports.JwtAuthGuardNoAnonymous = exports.JwtAuthGuard = void 0;
const common_1 = require("@nestjs/common");
const passport_1 = require("@nestjs/passport");
const common_2 = require("@nestjs/common");
let JwtAuthGuard = class JwtAuthGuard extends (0, passport_1.AuthGuard)('jwt') {
};
exports.JwtAuthGuard = JwtAuthGuard;
exports.JwtAuthGuard = JwtAuthGuard = __decorate([
(0, common_1.Injectable)()
], JwtAuthGuard);
let JwtAuthGuardNoAnonymous = class JwtAuthGuardNoAnonymous extends (0, passport_1.AuthGuard)('jwt') {
handleRequest(err, user, info) {
if (err || !user) {
throw err || new common_2.UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
}
if (user.isAnonymous) {
throw new common_2.UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
}
return user;
}
};
exports.JwtAuthGuardNoAnonymous = JwtAuthGuardNoAnonymous;
exports.JwtAuthGuardNoAnonymous = JwtAuthGuardNoAnonymous = __decorate([
(0, common_1.Injectable)()
], JwtAuthGuardNoAnonymous);
//# sourceMappingURL=jwt-auth.guard.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAGtC,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B"}
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAC7C,2CAAuD;AAGhD,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B;AAI9C,IAAM,uBAAuB,GAA7B,MAAM,uBAAwB,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;IAC3D,aAAa,CAAC,GAAQ,EAAE,IAAS,EAAE,IAAS;QAC1C,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YACjB,MAAM,GAAG,IAAI,IAAI,8BAAqB,CAAC,6CAA6C,CAAC,CAAC;QACxF,CAAC;QAGD,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;YACrB,MAAM,IAAI,8BAAqB,CAAC,gGAAgG,CAAC,CAAC;QACpI,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAbY,0DAAuB;kCAAvB,uBAAuB;IADnC,IAAA,mBAAU,GAAE;GACA,uBAAuB,CAanC"}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"jwt.strategy.js","sourceRoot":"","sources":["../../../src/auth/jwt.strategy.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAmE;AACnE,+CAAoD;AACpD,+CAAoD;AACpD,gEAA4D;AAGrD,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,IAAA,2BAAgB,EAAC,uBAAQ,CAAC;IACzD,YAAoB,MAAqB;QACvC,KAAK,CAAC;YACJ,cAAc,EAAE,yBAAU,CAAC,2BAA2B,EAAE;YACxD,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,cAAc;SACtD,CAAC,CAAC;QALe,WAAM,GAAN,MAAM,CAAe;IAMzC,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,OAAY;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,GAAG,EAAE;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,8BAAqB,CAAC,sDAAsD,CAAC,CAAC;QAC1F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AApBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAoBvB"}
{"version":3,"file":"jwt.strategy.js","sourceRoot":"","sources":["../../../src/auth/jwt.strategy.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAmE;AACnE,+CAAoD;AACpD,+CAAoD;AACpD,gEAA4D;AAGrD,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,IAAA,2BAAgB,EAAC,uBAAQ,CAAC;IACzD,YAAoB,MAAqB;QACvC,KAAK,CAAC;YACJ,cAAc,EAAE,yBAAU,CAAC,2BAA2B,EAAE;YACxD,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,cAAc;SACtD,CAAC,CAAC;QALe,WAAM,GAAN,MAAM,CAAe;IAMzC,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,OAAY;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,GAAG,EAAE;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,8BAAqB,CAAC,sDAAsD,CAAC,CAAC;QAC1F,CAAC;QAID,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAtBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAsBvB"}
+13 -8
View File
@@ -6,6 +6,18 @@ import { ParticipantRole } from '@prisma/client';
import { Reflector } from '@nestjs/core';
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { Cache } from 'cache-manager';
export declare class CommentGateway implements OnGatewayConnection {
server: Server;
handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void;
handleJoinPhoto(client: Socket, photoId: string): void;
notifyNewComment(tourId: string, data: any): void;
notifyNewPhotoComment(photoId: string, data: any): void;
handleJoinUser(client: Socket, userId: string): void;
notifyNewMessage(receiverId: string, data: any): void;
notifyConnectionAccepted(requesterId: string, data: any): void;
notifyJoinRequestAccepted(userId: string, data: any): void;
}
export declare const ROLES_KEY = "roles";
export declare const Roles: (...roles: ParticipantRole[]) => import("@nestjs/common").CustomDecorator<string>;
export declare class TourRoleGuard implements CanActivate {
@@ -19,12 +31,5 @@ export declare class EmailService {
private transporter;
constructor();
sendOTP(email: string, otp: string): Promise<any>;
}
export declare class CommentGateway implements OnGatewayConnection {
server: Server;
handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void;
handleJoinPhoto(client: Socket, photoId: string): void;
notifyNewComment(tourId: string, data: any): void;
notifyNewPhotoComment(photoId: string, data: any): void;
sendTourInvitation(email: string, tourTitle: string, inviteLink: string): Promise<any>;
}
+2450 -141
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -2,6 +2,7 @@
"name": "backend",
"version": "0.0.1",
"scripts": {
"build": "nest build",
"start:dev": "nest start --watch",
"db:generate": "dotenv -e ../.env -- prisma generate --schema=prisma/schema.prisma",
"db:migrate": "dotenv -e ../.env -- prisma migrate dev --schema=prisma/schema.prisma",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Location" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "TourParticipant" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,22 @@
/*
Warnings:
- The primary key for the `TourParticipant` table will be changed. If it partially fails, the table could be left without primary key constraint.
- A unique constraint covering the columns `[tourId,userId]` on the table `TourParticipant` will be added. If there are existing duplicate values, this will fail.
- The required column `id` was added to the `TourParticipant` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable
ALTER TABLE "TourParticipant" DROP CONSTRAINT "TourParticipant_pkey",
ADD COLUMN "displayName" TEXT,
ADD COLUMN "id" TEXT;
UPDATE "TourParticipant" SET "id" = md5(random()::text);
ALTER TABLE "TourParticipant" ALTER COLUMN "id" SET NOT NULL,
ALTER COLUMN "userId" DROP NOT NULL;
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_pkey" PRIMARY KEY ("id");
-- CreateIndex
CREATE UNIQUE INDEX "TourParticipant_tourId_userId_key" ON "TourParticipant"("tourId", "userId");
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "TourInvitation" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiredAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TourInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TourInvitation_token_key" ON "TourInvitation"("token");
-- CreateIndex
CREATE UNIQUE INDEX "TourInvitation_tourId_email_key" ON "TourInvitation"("tourId", "email");
-- AddForeignKey
ALTER TABLE "TourInvitation" ADD CONSTRAINT "TourInvitation_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "ConnectionStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ConnectionType" AS ENUM ('FRIEND', 'FAMILY');
-- CreateTable
CREATE TABLE "UserConnection" (
"id" TEXT NOT NULL,
"requesterId" TEXT NOT NULL,
"receiverId" TEXT NOT NULL,
"status" "ConnectionStatus" NOT NULL DEFAULT 'PENDING',
"type" "ConnectionType" NOT NULL DEFAULT 'FRIEND',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DirectMessage" (
"id" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"receiverId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DirectMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "UserConnection_requesterId_idx" ON "UserConnection"("requesterId");
-- CreateIndex
CREATE INDEX "UserConnection_receiverId_idx" ON "UserConnection"("receiverId");
-- CreateIndex
CREATE UNIQUE INDEX "UserConnection_requesterId_receiverId_key" ON "UserConnection"("requesterId", "receiverId");
-- CreateIndex
CREATE INDEX "DirectMessage_senderId_idx" ON "DirectMessage"("senderId");
-- CreateIndex
CREATE INDEX "DirectMessage_receiverId_idx" ON "DirectMessage"("receiverId");
-- AddForeignKey
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,31 @@
-- AlterTable
ALTER TABLE "DirectMessage" ADD COLUMN "attachmentUrl" TEXT,
ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION;
-- CreateTable
CREATE TABLE "TourMessage" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"attachmentUrl" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TourMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TourMessage_tourId_idx" ON "TourMessage"("tourId");
-- CreateIndex
CREATE INDEX "TourMessage_senderId_idx" ON "TourMessage"("senderId");
-- AddForeignKey
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,73 @@
-- CreateTable
CREATE TABLE "WordFilter" (
"id" TEXT NOT NULL,
"word" TEXT NOT NULL,
"replacement" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WordFilter_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModerationSetting" (
"id" TEXT NOT NULL,
"blockNsfw" BOOLEAN NOT NULL DEFAULT false,
"blurFaces" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "ModerationSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TourRating" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"targetUserId" TEXT NOT NULL,
"raterUserId" TEXT NOT NULL,
"honesty" INTEGER NOT NULL DEFAULT 5,
"transparency" INTEGER NOT NULL DEFAULT 5,
"enthusiasm" INTEGER NOT NULL DEFAULT 5,
"cheerfulness" INTEGER NOT NULL DEFAULT 5,
"seriousness" INTEGER NOT NULL DEFAULT 5,
"planning" INTEGER NOT NULL DEFAULT 5,
"survival" INTEGER NOT NULL DEFAULT 5,
"averageScore" DOUBLE PRECISION NOT NULL DEFAULT 5.0,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TourRating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TourShare" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TourShare_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WordFilter_word_key" ON "WordFilter"("word");
-- CreateIndex
CREATE UNIQUE INDEX "TourRating_tourId_targetUserId_raterUserId_key" ON "TourRating"("tourId", "targetUserId", "raterUserId");
-- CreateIndex
CREATE UNIQUE INDEX "TourShare_tourId_key" ON "TourShare"("tourId");
-- CreateIndex
CREATE UNIQUE INDEX "TourShare_token_key" ON "TourShare"("token");
-- AddForeignKey
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_raterUserId_fkey" FOREIGN KEY ("raterUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourShare" ADD CONSTRAINT "TourShare_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "BusinessReport" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"email" TEXT,
"address" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"reason" TEXT NOT NULL,
"isBlacklisted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BusinessReport_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,49 @@
-- AlterTable
ALTER TABLE "ModerationSetting" ADD COLUMN "trashRetentionDays" INTEGER NOT NULL DEFAULT 30;
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "deletedAt" TIMESTAMP(3),
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "deletedAt" TIMESTAMP(3),
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "TourNote" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "TourNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RecommendedLocation" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"email" TEXT,
"address" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"description" TEXT NOT NULL,
"stars" INTEGER NOT NULL DEFAULT 5,
"isApproved" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RecommendedLocation_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "TourNote" ADD CONSTRAINT "TourNote_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourNote" ADD CONSTRAINT "TourNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "flaggedAt" TIMESTAMP(3),
ADD COLUMN "flaggedReason" TEXT,
ADD COLUMN "isFlagged" BOOLEAN NOT NULL DEFAULT false;
+197 -4
View File
@@ -75,7 +75,14 @@ model User {
uploadedPhotos Photo[]
paidExpenses Expense[] @relation("ExpensePaidBy")
comments Comment[]
sentConnections UserConnection[] @relation("ConnectionRequester")
receivedConnections UserConnection[] @relation("ConnectionReceiver")
sentMessages DirectMessage[] @relation("MessageSender")
receivedMessages DirectMessage[] @relation("MessageReceiver")
tourMessages TourMessage[]
receivedRatings TourRating[] @relation("RatedUser")
sentRatings TourRating[] @relation("RatingUser")
tourNotes TourNote[]
}
model Tour {
@@ -99,6 +106,13 @@ model Tour {
joinRequests JoinRequest[]
legs Leg[]
photos Photo[]
invitations TourInvitation[]
tourMessages TourMessage[]
ratings TourRating[]
share TourShare?
notes TourNote[]
isDeleted Boolean @default(false)
deletedAt DateTime?
}
model JoinRequest {
@@ -118,14 +132,18 @@ model JoinRequest {
}
model TourParticipant {
id String @id @default(uuid())
tourId String
userId String
userId String?
role ParticipantRole @default(MEMBER)
displayName String?
adultCount Int @default(1)
childCount Int @default(0)
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([tourId, userId])
@@unique([tourId, userId])
}
model Leg {
@@ -162,6 +180,8 @@ model Location {
expenses Expense[]
photos Photo[]
comments Comment[]
createdAt DateTime @default(now())
}
model Expense {
@@ -189,11 +209,16 @@ model Photo {
capturedAt DateTime @default(now())
metadata Json?
privacy PrivacyLevel @default(TOUR_ONLY)
isFlagged Boolean @default(false)
flaggedReason String?
flaggedAt DateTime?
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])
comments Comment[]
isDeleted Boolean @default(false)
deletedAt DateTime?
}
model Comment {
@@ -207,3 +232,171 @@ model Comment {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model TourInvitation {
id String @id @default(uuid())
tourId String
email String
role ParticipantRole @default(MEMBER)
token String @unique
createdAt DateTime @default(now())
expiredAt DateTime
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
@@unique([tourId, email])
}
enum ConnectionStatus {
PENDING
ACCEPTED
REJECTED
}
enum ConnectionType {
FRIEND
FAMILY
}
model UserConnection {
id String @id @default(uuid())
requesterId String
receiverId String
status ConnectionStatus @default(PENDING)
type ConnectionType @default(FRIEND)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
requester User @relation("ConnectionRequester", fields: [requesterId], references: [id], onDelete: Cascade)
receiver User @relation("ConnectionReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
@@unique([requesterId, receiverId])
@@index([requesterId])
@@index([receiverId])
}
model DirectMessage {
id String @id @default(uuid())
senderId String
receiverId String
content String @db.Text
attachmentUrl String?
latitude Float?
longitude Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade)
receiver User @relation("MessageReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
@@index([senderId])
@@index([receiverId])
}
model TourMessage {
id String @id @default(uuid())
tourId String
senderId String
content String @db.Text
attachmentUrl String?
latitude Float?
longitude Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
@@index([tourId])
@@index([senderId])
}
model WordFilter {
id String @id @default(uuid())
word String @unique
replacement String
createdAt DateTime @default(now())
}
model ModerationSetting {
id String @id @default(uuid())
blockNsfw Boolean @default(false)
blurFaces Boolean @default(false)
trashRetentionDays Int @default(30)
}
model TourRating {
id String @id @default(uuid())
tourId String
targetUserId String
raterUserId String
honesty Int @default(5)
transparency Int @default(5)
enthusiasm Int @default(5)
cheerfulness Int @default(5)
seriousness Int @default(5)
planning Int @default(5)
survival Int @default(5)
averageScore Float @default(5.0)
comment String? @db.Text
createdAt DateTime @default(now())
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
targetUser User @relation("RatedUser", fields: [targetUserId], references: [id], onDelete: Cascade)
raterUser User @relation("RatingUser", fields: [raterUserId], references: [id], onDelete: Cascade)
@@unique([tourId, targetUserId, raterUserId])
}
model TourShare {
id String @id @default(uuid())
tourId String @unique
token String @unique @default(uuid())
isEnabled Boolean @default(true)
createdAt DateTime @default(now())
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
}
model BusinessReport {
id String @id @default(uuid())
type String // e.g. "USER", "RESTAURANT", "HOTEL", "HOMESTAY"
name String
phone String?
email String?
address String?
latitude Float?
longitude Float?
reason String @db.Text
isBlacklisted Boolean @default(false)
createdAt DateTime @default(now())
}
model TourNote {
id String @id @default(uuid())
tourId String
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isDeleted Boolean @default(false)
deletedAt DateTime?
}
model RecommendedLocation {
id String @id @default(uuid())
type String // e.g. "RESTAURANT", "HOTEL", "HOMESTAY"
name String
phone String?
email String?
address String?
latitude Float?
longitude Float?
description String @db.Text
stars Int @default(5)
isApproved Boolean @default(false)
createdAt DateTime @default(now())
}
+19 -1
View File
@@ -1,5 +1,23 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UnauthorizedException } from '@nestjs/common';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
export class JwtAuthGuard extends AuthGuard('jwt') {}
// Guard that rejects anonymous/guest users - used for dashboard and sensitive endpoints
@Injectable()
export class JwtAuthGuardNoAnonymous extends AuthGuard('jwt') {
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
}
// Reject anonymous/temporary users
if (user.isAnonymous) {
throw new UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
}
return user;
}
}
+2
View File
@@ -22,6 +22,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
}
// Note: We allow anonymous users to pass JWT validation
// Individual endpoints decide whether to accept anonymous users based on their guard
return user;
}
}
+2315 -143
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
async function test() {
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'owner@travel.com', password: '123456' })
});
const loginData = await loginRes.json();
if (loginRes.ok) {
const token = loginData.access_token;
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Tour ID from seed
const res = await fetch(`http://localhost:3001/api/v1/tours/${tourId}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ displayName: 'Offline Member X', role: 'MEMBER' })
});
console.log('Add status:', res.status);
const data = await res.json();
console.log('Add response:', data);
} else {
console.error('Login failed:', loginData);
}
}
test().catch(console.error);
+14
View File
@@ -0,0 +1,14 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const users = await prisma.user.findMany();
console.log('--- Users in DB ---');
console.log(users);
const participants = await prisma.tourParticipant.findMany();
console.log('--- Tour Participants in DB ---');
console.log(participants);
}
main().catch(console.error).finally(() => prisma.$disconnect());
+17
View File
@@ -0,0 +1,17 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tours = await prisma.tour.findMany({
include: {
participants: {
include: {
user: { select: { email: true, name: true } }
}
}
}
});
console.log(JSON.stringify(tours, null, 2));
}
main().finally(() => prisma.$disconnect());
+36
View File
@@ -0,0 +1,36 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Use existing tour ID from seed
console.log('Inserting first manual member...');
try {
const p1 = await prisma.tourParticipant.create({
data: {
tourId,
role: 'MEMBER',
displayName: 'Manual Member 1'
}
});
console.log('Inserted:', p1);
} catch (err) {
console.error('Failed to insert first:', err);
}
console.log('Inserting second manual member...');
try {
const p2 = await prisma.tourParticipant.create({
data: {
tourId,
role: 'MEMBER',
displayName: 'Manual Member 2'
}
});
console.log('Inserted:', p2);
} catch (err) {
console.error('Failed to insert second:', err);
}
}
main().catch(console.error).finally(() => prisma.$disconnect());
+22
View File
@@ -0,0 +1,22 @@
async function test() {
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'photomember@travel.com', password: '123456' })
});
const loginData = await loginRes.json();
if (loginRes.ok) {
const token = loginData.access_token;
const usersRes = await fetch('http://localhost:3001/api/v1/users?q=owner@travel.com', {
headers: { 'Authorization': `Bearer ${token}` }
});
console.log('Users status:', usersRes.status);
const usersData = await usersRes.json();
console.log('Users data:', usersData);
} else {
console.error('Login failed:', loginData);
}
}
test().catch(console.error);
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

+73
View File
@@ -0,0 +1,73 @@
services:
postgres:
image: postgres:15-alpine
container_name: yotrip-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: travel_db
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: yotrip-redis
ports:
- "6379:6379"
backend:
build:
context: ./backend
target: development
container_name: yotrip-backend
command: >
sh -c "npx prisma migrate dev --schema=prisma/schema.prisma && npm run start:dev"
ports:
- "3001:3001"
volumes:
- ./backend:/usr/src/app
- /usr/src/app/node_modules
environment:
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
REDIS_URL: "redis://redis:6379"
PORT: 3001
ADMIN_SECRET_KEY: "yotrip_secret_admin_key"
JWT_SECRET: "super-secret"
FRONTEND_URL: "http://localhost:5173"
NODE_ENV: development
SMTP_HOST: "${SMTP_HOST}"
SMTP_PORT: "${SMTP_PORT}"
SMTP_SECURE: "${SMTP_SECURE}"
SMTP_USER: "${SMTP_USER}"
SMTP_PASS: "${SMTP_PASS}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
frontend:
build:
context: ./frontend
target: development
container_name: yotrip-frontend
command: npm run dev -- --host 0.0.0.0
ports:
- "5173:5173"
volumes:
- ./frontend:/usr/src/app
- /usr/src/app/node_modules
environment:
VITE_API_URL: "http://localhost:3001"
depends_on:
- backend
volumes:
pg_data:
+68
View File
@@ -0,0 +1,68 @@
services:
postgres:
image: postgres:15-alpine
container_name: yotrip-db-prod
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: travel_db
volumes:
- pg_data_prod:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: yotrip-redis-prod
restart: always
backend:
build:
context: ./backend
target: production
container_name: yotrip-backend-prod
restart: always
command: >
sh -c "npx prisma migrate deploy --schema=prisma/schema.prisma && node dist/src/main.js"
ports:
- "3001:3001"
volumes:
- ./backend/uploads:/usr/src/app/uploads
environment:
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
REDIS_URL: "redis://redis:6379"
PORT: 3001
ADMIN_SECRET_KEY: "${ADMIN_SECRET_KEY}"
JWT_SECRET: "super-secret"
FRONTEND_URL: "${FRONTEND_URL}"
NODE_ENV: production
SMTP_HOST: "${SMTP_HOST}"
SMTP_PORT: "${SMTP_PORT}"
SMTP_SECURE: "${SMTP_SECURE}"
SMTP_USER: "${SMTP_USER}"
SMTP_PASS: "${SMTP_PASS}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
frontend:
build:
context: ./frontend
target: production
args:
- VITE_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
container_name: yotrip-frontend-prod
restart: always
ports:
- "3002:80"
depends_on:
- backend
volumes:
pg_data_prod:
+68
View File
@@ -0,0 +1,68 @@
services:
postgres:
image: postgres:15-alpine
container_name: yotrip-db-prod
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: travel_db
volumes:
- pg_data_prod:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: yotrip-redis-prod
restart: always
backend:
build:
context: ./backend
target: production
container_name: yotrip-backend-prod
restart: always
command: >
sh -c "npx prisma migrate deploy --schema=prisma/schema.prisma && node dist/src/main.js"
ports:
- "3001:3001"
volumes:
- ./backend/uploads:/usr/src/app/uploads
environment:
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
REDIS_URL: "redis://redis:6379"
PORT: 3001
ADMIN_SECRET_KEY: "${ADMIN_SECRET_KEY}"
JWT_SECRET: "super-secret"
FRONTEND_URL: "${FRONTEND_URL}"
NODE_ENV: production
SMTP_HOST: "${SMTP_HOST}"
SMTP_PORT: "${SMTP_PORT}"
SMTP_SECURE: "${SMTP_SECURE}"
SMTP_USER: "${SMTP_USER}"
SMTP_PASS: "${SMTP_PASS}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
frontend:
build:
context: ./frontend
target: production
args:
- VITE_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
container_name: yotrip-frontend-prod
restart: always
ports:
- "3002:80"
depends_on:
- backend
volumes:
pg_data_prod:
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
.env
.git
.gitignore
+28
View File
@@ -0,0 +1,28 @@
FROM node:22-alpine AS base
WORKDIR /usr/src/app
COPY package*.json ./
# Development stage
FROM base AS development
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
# Build stage for production
FROM base AS build
ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
RUN npm install
COPY . .
RUN npm run build
# Production stage using Nginx
FROM nginx:1.25-alpine AS production
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 224 KiB

+4 -3
View File
@@ -4,11 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-CtrQmjY1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i0kJVU1C.css">
<script type="module" crossorigin src="/assets/index-Chfn55jq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D_50XTpY.css">
</head>
<body>
<body>
<div id="root"></div>
</body>
</html>
+1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -4,9 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title>
</head>
<body>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
+43
View File
@@ -0,0 +1,43 @@
server {
listen 80;
server_name yotrip.labz.io.vn localhost;
client_max_body_size 50M;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/v1/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Serve uploaded files from backend
location /uploads/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Proxy WebSocket connection
location /socket.io/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+4
View File
@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"date-fns": "^4.4.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"leaflet": "^1.9.4",
"lucide-react": "^0.284.0",
"react": "^18.3.1",
@@ -25,6 +28,7 @@
"@tailwindcss/postcss": "^4.3.1",
"@types/leaflet": "^1.9.12",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.15",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 224 KiB

File diff suppressed because one or more lines are too long
+226 -35
View File
@@ -1,56 +1,110 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { LandingPage } from './pages/LandingPage';
import { ExploreMap } from './pages/ExploreMap';
import { TourDetailPage } from './pages/TourDetailPage';
import SignupPage from './pages/SignupPage';
import { MyPhotosPage } from './pages/MyPhotosPage';
import { MyNotePage } from './pages/MyNotePage';
import { useTourStore } from './store/useTourStore';
import { JoinTourPage } from './pages/JoinTourPage';
import { MemberDashboard } from './pages/MemberDashboard';
import { AdminDashboard } from './pages/AdminDashboard';
import { ShareJourneyPage } from './pages/ShareJourneyPage';
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 pathParts = window.location.pathname.split('/');
const isJourneyShare = pathParts[1] === 'journey' && pathParts[2];
const journeyTokenVal = isJourneyShare ? pathParts[2] : null;
const [user, setUser] = useState<any>(null);
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes'>(viewTourId ? 'tourDetail' : 'landing');
const [shareJourneyToken, setShareJourneyToken] = useState<string | null>(journeyTokenVal);
const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'admin' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney'>(
journeyTokenVal ? 'shareJourney' : (viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing'))
);
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
// Lấy action từ store
const fetchTour = useTourStore(state => state.fetchTour);
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails);
const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore');
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const viewTourId = params.get('viewTour');
const isJoinTour = window.location.pathname === '/join-tour' || params.has('token');
const pathParts = window.location.pathname.split('/');
const journeyToken = pathParts[1] === 'journey' && pathParts[2] ? pathParts[2] : null;
if (viewTourId) {
// Không gọi replaceState ngay để tránh mất ID khi component re-render hoặc refresh
} else {
// Kiểm tra đăng nhập bình thường nếu không có tham số viewTour
const token = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (token && storedUser) {
try {
setUser(JSON.parse(storedUser));
setCurrentPage('explore'); // Chuyển đến bản đồ khám phá nếu đã đăng nhập
} catch (e) {
console.error("Lỗi khi phân tích dữ liệu người dùng từ localStorage", e);
localStorage.removeItem('token');
localStorage.removeItem('user');
setCurrentPage('landing'); // Quay về trang Landing nếu dữ liệu lỗi
}
} else {
setCurrentPage('landing'); // Quay về trang Landing nếu chưa đăng nhập
// Check if user just finished uploading from a public tour
const fromPublicUpload = localStorage.getItem('fromPublicUpload');
if (fromPublicUpload) {
localStorage.removeItem('fromPublicUpload');
setCurrentPage('landing');
return;
}
// Khôi phục thông tin đăng nhập nếu có
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
const storedUser = localStorage.getItem('user');
const storedGuestUser = localStorage.getItem('guest_user');
let loggedInUser = null;
if (token && storedUser) {
try {
loggedInUser = JSON.parse(storedUser);
setUser(loggedInUser);
} catch (e) {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
}
}, []); // Chỉ chạy một lần khi component mount
if (journeyToken) {
setShareJourneyToken(journeyToken);
setCurrentPage('shareJourney');
} else if (isJoinTour) {
setCurrentPage('joinTour');
} else if (viewTourId) {
setCurrentPage('tourDetail');
} else {
// CRITICAL: Check for guest token FIRST - guests should NEVER access dashboard
// regardless of whether they also have other tokens
if (guestToken) {
setCurrentPage('landing');
} else if (loggedInUser) {
// Real authenticated user (no guest token)
if (loggedInUser.isAdmin) {
setCurrentPage('admin');
} else {
setCurrentPage('dashboard');
}
} else {
setCurrentPage('landing');
}
}
}, []);
const handleLoginSuccess = (loggedInUser: any) => {
setUser(loggedInUser);
setCurrentPage('explore');
// Nếu có pending token, ta vẫn giữ ở trang joinTour để nó tự động thực hiện join
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else if (loggedInUser.isAdmin) {
// Nếu là admin, chuyển đến admin dashboard
setCurrentPage('admin');
} else {
// Only set to dashboard if this is a real user (has token), not a guest
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
if (token && !guestToken) {
setCurrentPage('dashboard');
} else {
setCurrentPage('landing');
}
}
};
const handleLogout = () => {
@@ -60,35 +114,144 @@ function App() {
setCurrentPage('landing');
};
const handleViewTour = (tourId: string) => {
const handleViewTour = (tourId: string, fromPage?: 'explore' | 'dashboard') => {
setCurrentTourId(tourId);
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
setPreviousPage(fromPage || (currentPage === 'dashboard' ? 'dashboard' : 'explore'));
setCurrentPage('tourDetail');
};
const handleBackFromTourDetail = () => {
// Check if this was a public view BEFORE clearing the flag
const wasPublicView = isPublicTourView;
setCurrentTourId(null);
setIsPublicTourView(false);
// Quay về trang khám phá nếu đã đăng nhập, ngược lại quay về Landing
if (user) {
setCurrentPage('explore');
// If user was viewing a public tour, redirect to index/landing page
// Otherwise redirect based on authentication and previous page
if (wasPublicView) {
// Public tour view - always redirect to index/landing
setCurrentPage('landing');
} else if (user) {
// Authenticated user viewing their own tour - go back to previous page
setCurrentPage(previousPage);
} else {
// Not authenticated and not public view - go to landing
setCurrentPage('landing');
}
};
const handleBackFromSignup = () => {
setCurrentPage('landing');
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else {
setCurrentPage('landing');
}
};
const handleSignupSuccess = () => {
setCurrentPage('landing'); // Quay về trang Landing sau khi đăng ký thành công
// Sau khi đăng ký, ta có thể tự động đăng nhập hoặc quay lại landing/joinTour
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
const token = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (token && storedUser) {
try {
const loggedInUser = JSON.parse(storedUser);
setUser(loggedInUser);
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else {
setCurrentPage('dashboard');
}
return;
} catch (e) {}
}
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else {
setCurrentPage('landing');
}
};
const handleBackFromExplore = () => {
// Only allow real users (with token, not guest_token)
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
const isRealUser = token && !guestToken;
if (user && isRealUser) {
setCurrentPage('dashboard');
} else {
setCurrentPage('landing');
}
};
const handleGoToDashboard = () => {
// Only allow real users (with token, not guest_token)
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
const isRealUser = token && !guestToken;
if (user && isRealUser) {
setPreviousPage('explore');
setCurrentPage('dashboard');
} else {
setCurrentPage('landing');
}
};
const handleGoToHome = () => {
window.history.pushState({}, '', '/');
setShareJourneyToken(null);
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
// Only real authenticated users can access dashboard, not guests
const isRealUser = token && !guestToken;
setCurrentPage(isRealUser ? 'dashboard' : 'landing');
};
return (
<ConfirmProvider>
<NotificationProvider>
{(() => {
if (currentPage === 'admin') {
return (
<AdminDashboard
user={user}
onNavigate={setCurrentPage}
/>
);
}
if (currentPage === 'dashboard') {
// SECURITY: Prevent any guest from accessing dashboard
const guestToken = localStorage.getItem('guest_token');
if (guestToken) {
console.warn('[App] Guest user attempted to access dashboard - forcing redirect to landing');
setCurrentPage('landing');
return (
<LandingPage
onLoginSuccess={handleLoginSuccess}
onGoToSignup={() => setCurrentPage('signup')}
/>
);
}
return (
<MemberDashboard
user={user}
onLogout={handleLogout}
onExploreTours={() => setCurrentPage('explore')}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
/>
);
}
if (currentPage === 'tourDetail') {
return (
<TourDetailPage
@@ -101,18 +264,19 @@ function App() {
}
if (currentPage === 'notes') {
return <MyNotePage onBack={() => setCurrentPage('tourDetail')} />;
return <MyNotePage tourId={currentTourId!} onBack={() => setCurrentPage('tourDetail')} />;
}
if (currentPage === 'explore') {
return (
<ExploreMap
onBack={handleBackFromTourDetail}
onBack={handleBackFromExplore}
onLogout={handleLogout}
user={user}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
onLoginSuccess={handleLoginSuccess}
onGoToDashboard={handleGoToDashboard}
/>
);
}
@@ -127,6 +291,33 @@ function App() {
return <SignupPage onBack={handleBackFromSignup} onSuccess={handleSignupSuccess} />;
}
if (currentPage === 'joinTour') {
return (
<JoinTourPage
onLoginSuccess={handleLoginSuccess}
onGoToSignup={() => setCurrentPage('signup')}
onViewTour={(tourId) => {
setCurrentTourId(tourId);
setIsPublicTourView(false);
setCurrentPage('tourDetail');
}}
onGoToHome={() => {
const loggedIn = !!localStorage.getItem('token');
setCurrentPage(loggedIn ? 'explore' : 'landing');
}}
/>
);
}
if (currentPage === 'shareJourney') {
return (
<ShareJourneyPage
token={shareJourneyToken!}
onGoToHome={handleGoToHome}
/>
);
}
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
})()}
</NotificationProvider>
+30 -16
View File
@@ -1,6 +1,6 @@
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 { X, MapPin, Loader2, 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';
@@ -132,22 +132,36 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
[formData.latitude, formData.longitude]
);
// Tự động cập nhật vị trí bản đồ theo chặng được chọn (Lấy điểm cuối của chặng đó làm tham chiếu)
// Tự động cập nhật vị trí bản đồ theo chặng được chọn (Lấy điểm cuối của chặng trước/hiện tại làm tham chiếu tiếp nối)
useEffect(() => {
if (isOpen && !editingLocation && formData.legId && !formData.name) {
const selectedLeg = legs.find(l => l.id === formData.legId);
if (selectedLeg && selectedLeg.locations && selectedLeg.locations.length > 0) {
// Di chuyển đến địa điểm cuối cùng của chặng để người dùng thấy điểm nối tiếp
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
setFormData(prev => ({
...prev,
latitude: lastLoc.latitude,
longitude: lastLoc.longitude
}));
} else {
// Nếu chặng chưa có điểm nào, mặc định dùng vị trí trung tâm hiện tại của tour
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
if (selectedLeg) {
if (selectedLeg.locations && selectedLeg.locations.length > 0) {
// Di chuyển đến địa điểm cuối cùng của chặng hiện tại để người dùng thấy điểm nối tiếp
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
setFormData(prev => ({
...prev,
latitude: lastLoc.latitude,
longitude: lastLoc.longitude
}));
} else {
// Chặng trống -> Lấy địa điểm cuối của chặng trước làm tọa độ tiếp nối
const currentLegIdx = legs.findIndex(l => l.id === formData.legId);
const prevLeg = currentLegIdx > 0 ? legs[currentLegIdx - 1] : null;
if (prevLeg && prevLeg.locations && prevLeg.locations.length > 0) {
const lastLoc = prevLeg.locations[prevLeg.locations.length - 1];
setFormData(prev => ({
...prev,
latitude: lastLoc.latitude,
longitude: lastLoc.longitude
}));
} else {
// Nếu không có chặng trước hoặc chặng trước trống, mặc định dùng vị trí trung tâm hiện tại của tour
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
}
}
}
}
}, [formData.legId, isOpen, editingLocation, legs, mapCenter]);
@@ -484,12 +498,12 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
<option value="">-- Chọn người thanh toán --</option>
{currentTour?.participants?.map((p: any) => {
const name = p.user?.name;
{currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.map((p: any) => {
const name = p.user?.name || p.displayName;
const email = p.user?.email;
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
return (
<option key={p.userId} value={p.userId}>{label || p.userId}</option>
<option key={p.id} value={p.userId || p.id}>{label || p.userId || p.id}</option>
);
})}
</select>
+301 -95
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react';
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react';
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock } from 'lucide-react';
import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
@@ -7,7 +7,7 @@ interface AddMemberModalProps {
isOpen: boolean;
onClose: () => void;
tourId: string;
participants?: Array<{ userId: string; role: string; user?: { id: string; name: string; email: string } }>;
participants?: Array<{ id: string; userId?: string | null; role: string; displayName?: string | null; user?: { id: string; name: string; email: string } | null }>;
joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>;
onRemoveMember?: (userId: string) => Promise<void>;
onMemberAdded?: () => void;
@@ -15,7 +15,8 @@ interface AddMemberModalProps {
isPublicView?: boolean; // New prop to indicate public view
}
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole, isPublicView }) => {
const [activeTab, setActiveTab] = useState<'search' | 'email'>('search');
const [query, setQuery] = useState('');
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
@@ -26,14 +27,49 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const [submitError, setSubmitError] = useState('');
const [actionLoading, setActionLoading] = useState<string | null>(null);
// Trạng thái cho tab mời qua email
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY'>('MEMBER');
const [inviteLoading, setInviteLoading] = useState(false);
const [inviteError, setInviteError] = useState('');
const [inviteSuccess, setInviteSuccess] = useState('');
const confirm = useConfirm();
const notify = useNotification();
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId).filter(Boolean) as string[]), [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]);
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
const canInviteByEmail = userRole === 'OWNER' || userRole === 'MANAGER';
const handleManualAdd = async () => {
const name = query.trim();
if (!name) return;
setSubmitting(true);
setSubmitError('');
try {
const res = await fetch(`/api/v1/tours/${tourId}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ displayName: name, role }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || data.error || 'Thao tác thất bại');
}
await onMemberAdded?.();
onClose();
} catch (err: any) {
setSubmitError(err.message || 'Thao tác thất bại');
} finally {
setSubmitting(false);
}
};
const fetchUsers = async () => {
setLoading(true);
@@ -52,10 +88,44 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
}
};
const handleSendInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim()) return;
setInviteLoading(true);
setInviteError('');
setInviteSuccess('');
try {
const res = await fetch(`/api/v1/tours/${tourId}/invitations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'Gửi lời mời thất bại');
}
setInviteSuccess(`Lời mời đã được gửi thành công đến ${inviteEmail}!`);
setInviteEmail('');
notify({ title: 'Thành công', message: `Lời mời đã gửi tới ${inviteEmail}`, type: 'success' });
await onMemberAdded?.();
} catch (err: any) {
setInviteError(err.message || 'Thao tác thất bại');
} finally {
setInviteLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
fetchUsers();
}, [isOpen]);
if (!isOpen || activeTab !== 'search') return;
const delayDebounceFn = setTimeout(() => {
fetchUsers();
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [query, isOpen, activeTab]);
useEffect(() => {
if (!isOpen) {
@@ -64,10 +134,15 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
setRole('MEMBER');
setFetchError('');
setSubmitError('');
setInviteEmail('');
setInviteRole('MEMBER');
setInviteError('');
setInviteSuccess('');
setActiveTab('search');
}
}, [isOpen]);
const handleRemove = async (userId: string, memberName: string) => {
const handleRemove = async (memberIdOrUserId: string, memberName: string) => {
if (!onRemoveMember) return;
const isConfirmed = await confirm({
title: 'Xóa thành viên',
@@ -76,14 +151,14 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
if (isConfirmed) {
try {
await onRemoveMember(userId);
await onRemoveMember(memberIdOrUserId);
} catch (err: any) {
setSubmitError(err.message || 'Không thể xóa thành viên');
}
}
};
const handleRequestAction = async (reqId: string, action: 'accept' | 'reject', userName: string) => {
const handleRequestAction = async (reqId: string, action: 'accept' | 'reject') => {
if (!onMemberAdded) return;
setActionLoading(reqId);
try {
@@ -145,10 +220,10 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div>
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
<UserPlus className="w-5 h-5 text-blue-600" /> {canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'}
<UserPlus className="w-5 h-5 text-blue-600" /> {canCreateDirectly ? 'Thành viên hành trình' : 'Mời tham gia tour'}
</h2>
<p className="text-xs text-gray-500">
{canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'}
{canCreateDirectly ? 'Quản lý, thêm thành viên và mời người khác tham gia.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'}
</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
@@ -156,11 +231,37 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</button>
</div>
<div className="p-5 space-y-4">
{canInviteByEmail && (
<div className="flex border-b border-gray-100 bg-gray-50/30">
<button
onClick={() => setActiveTab('search')}
className={`flex-1 py-3 text-center text-sm font-bold border-b-2 transition-all ${
activeTab === 'search'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Tìm thành viên
</button>
<button
onClick={() => setActiveTab('email')}
className={`flex-1 py-3 text-center text-sm font-bold border-b-2 transition-all ${
activeTab === 'email'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Mời qua Email
</button>
</div>
)}
<div className="p-5 space-y-4 overflow-y-auto flex-1">
{/* Danh sách thành viên hiện tại */}
<div>
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.length})</p>
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.filter(p => p.user || p.displayName).length})</p>
<div className="flex flex-wrap gap-3">
{participants.map((p) => {
{participants.filter(p => p.user || p.displayName).map((p) => {
const rawToken = localStorage.getItem('token');
let currentUserId: string | null = null;
try {
@@ -172,15 +273,16 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const isCurrentUser = currentUserId && p.userId === currentUserId;
const isOwner = p.role === 'OWNER';
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
const memberName = p.user?.name || p.displayName || p.userId || 'Thành viên';
return (
<div key={p.userId} className="flex flex-col items-center gap-1">
<div key={p.id} className="flex flex-col items-center gap-1">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
{p.user?.name?.charAt(0) || '?'}
{memberName.charAt(0)}
</div>
{canRemove && (
<button
onClick={() => handleRemove(p.userId, p.user?.name || p.userId)}
onClick={() => handleRemove(p.userId || p.id, memberName)}
className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
aria-label="Remove item"
>
@@ -188,7 +290,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</button>
)}
</div>
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{p.user?.name || p.userId}</span>
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{memberName}</span>
</div>
);
})}
@@ -198,6 +300,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</div>
</div>
{/* Danh sách chờ duyệt */}
{joinRequests.length > 0 && (
<div>
<p className="text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1">
@@ -213,7 +316,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<button
type="button"
disabled={actionLoading === req.id}
onClick={() => handleRequestAction(req.id, 'accept', req.user?.name || req.userId)}
onClick={() => handleRequestAction(req.id, 'accept')}
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"
>
@@ -222,7 +325,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<button
type="button"
disabled={actionLoading === req.id}
onClick={() => handleRequestAction(req.id, 'reject', req.user?.name || req.userId)}
onClick={() => handleRequestAction(req.id, 'reject')}
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"
>
@@ -237,87 +340,190 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</div>
)}
{canCreateDirectly && (
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền</label>
<select
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={role}
onChange={(e) => setRole(e.target.value as any)}
>
<option value="OWNER">OWNER</option>
<option value="MANAGER">MANAGER</option>
<option value="MEMBER">MEMBER</option>
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE</option>
<option value="VIEWER_ONLY">VIEWER_ONLY</option>
</select>
</div>
)}
<hr className="border-gray-100" />
<div className="space-y-2">
{fetchError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{fetchError}
</div>
)}
{submitError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{submitError}
</div>
)}
{loading ? (
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
) : (
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1">
{visibleUsers.map((u) => {
const isSelected = selectedUser === u.id;
return (
{activeTab === 'search' ? (
<div className="space-y-4">
{canCreateDirectly && (
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền khi thêm</label>
<select
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm font-bold text-gray-800"
value={role}
onChange={(e) => setRole(e.target.value as any)}
>
<option value="OWNER">OWNER</option>
<option value="MANAGER">MANAGER</option>
<option value="MEMBER">MEMBER</option>
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE</option>
<option value="VIEWER_ONLY">VIEWER_ONLY</option>
</select>
</div>
)}
<div className="space-y-2">
<div className="relative mb-2">
<input
type="text"
placeholder="Tìm kiếm theo tên hoặc địa chỉ email..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-10 pr-10 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-gray-800"
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
{query && (
<button
key={u.id}
onClick={() => setSelectedUser(u.id)}
disabled={requestUserIds.has(u.id)}
className={`w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${
isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'
} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`}
type="button"
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-200 rounded-full text-gray-400 transition-colors"
>
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || '?'}
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
<div className="text-[11px] text-gray-500">{u.email}</div>
<div className="text-[11px] text-gray-400">{u.phone || ''} {u.address ? `${u.address}` : ''}</div>
</div>
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100">USER</span>
)}
{isSelected && <Shield className="w-3 h-3 text-blue-600" />}
</div>
<X className="w-4 h-4" />
</button>
);
})}
{!loading && visibleUsers.length === 0 && (
<div className="text-center py-8 text-sm text-gray-500">Không tìm thấy người dùng phù hợp</div>
)}
</div>
{fetchError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{fetchError}
</div>
)}
{submitError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{submitError}
</div>
)}
{loading ? (
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
) : (
<div className="space-y-2 max-h-[30vh] overflow-y-auto pr-1">
{query.trim() && canCreateDirectly && (
<button
type="button"
onClick={handleManualAdd}
disabled={submitting}
className="w-full flex items-center gap-3 p-3 rounded-2xl border border-dashed border-blue-300 hover:border-blue-400 bg-blue-50/20 text-blue-700 transition-all text-left mb-2"
>
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
+
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold">Thêm thành viên thủ công</div>
<div className="text-[11px] text-gray-500">Thêm "{query.trim()}" trực tiếp vào danh sách thành viên</div>
</div>
{submitting ? (
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
) : (
<span className="px-2.5 py-1 bg-blue-600 text-white rounded-xl text-xs font-bold transition-all">Thêm</span>
)}
</button>
)}
{visibleUsers.map((u) => {
const isSelected = selectedUser === u.id;
return (
<button
key={u.id}
onClick={() => setSelectedUser(u.id)}
disabled={requestUserIds.has(u.id)}
className={`w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${
isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'
} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`}
>
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || '?'}
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
<div className="text-[11px] text-gray-500">{u.email}</div>
</div>
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
{isSelected && <Shield className="w-3 h-3 text-blue-600" />}
</div>
</button>
);
})}
{!loading && visibleUsers.length === 0 && (
<div className="text-center py-8 text-sm text-gray-500">Không tìm thấy người dùng phù hợp</div>
)}
</div>
)}
</div>
)}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
Hủy
</button>
<button
disabled={!selectedUser || submitting}
onClick={handleAdd}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all"
>
{submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời'}
</button>
</div>
</div>
) : (
<form onSubmit={handleSendInvite} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 ml-1">Email người nhận</label>
<input
required
type="email"
placeholder="nhap.email@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-gray-800"
/>
</div>
<div className="p-5 border-t border-gray-100 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
Hủy
</button>
<button
disabled={!selectedUser || submitting}
onClick={handleAdd}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all"
>
{submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời'}
</button>
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 ml-1">Vai trò trong Tour</label>
<select
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm font-bold text-gray-800"
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as any)}
>
<option value="MEMBER">MEMBER (Thành viên tài chính)</option>
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE (Thành viên phi tài chính)</option>
<option value="MANAGER">MANAGER (Đng quản trị viên)</option>
<option value="VIEWER_ONLY">VIEWER_ONLY (Chỉ xem thông tin)</option>
</select>
</div>
{inviteError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{inviteError}
</div>
)}
{inviteSuccess && (
<div className="p-3 bg-green-50 text-green-700 rounded-xl text-xs font-bold border border-green-100">
{inviteSuccess}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors"
>
Đóng
</button>
<button
type="submit"
disabled={inviteLoading || !inviteEmail}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all flex items-center gap-2"
>
{inviteLoading ? (
<>
Đang gửi...
<Loader2 className="w-4 h-4 animate-spin" />
</>
) : (
'Gửi thư mời'
)}
</button>
</div>
</form>
)}
</div>
</div>
</div>
+81 -31
View File
@@ -2,18 +2,22 @@ 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';
import { processImageModeration } from '@/hooks/useImageModeration';
import { compressImage } from '../utils/image';
interface AddPhotoModalProps {
isOpen: boolean;
onClose: () => void;
tourId: string;
onSuccess?: () => void;
isPublicView?: boolean;
}
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess }) => {
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess, isPublicView }) => {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
const fetchTour = useTourStore(state => state.fetchTour);
@@ -26,34 +30,55 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
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;
setIsProcessing(true);
notify({ title: 'Đang kiểm duyệt...', message: 'Đang kiểm tra và lọc hình ảnh của bạn...', type: 'info' });
try {
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;
}
// Nén ảnh trước
const compressedFile = await compressImage(file);
// 2. Chạy kiểm duyệt hình ảnh
const moderationResult = await processImageModeration(compressedFile);
if (moderationResult.blocked) {
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
continue;
}
const processedFile = moderationResult.file;
const previewUrl = URL.createObjectURL(processedFile);
// 3. 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(processedFile);
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' });
}
}
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]);
} catch (err) {
console.error('File checking error:', err);
notify({ title: 'Lỗi', message: 'Lỗi trong quá trình kiểm duyệt ảnh.', type: 'error' });
} finally {
setIsProcessing(false);
}
setSelectedFiles(prev => [...prev, ...newValidFiles]);
setPreviews(prev => [...prev, ...newValidPreviews]);
}
};
@@ -112,13 +137,28 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
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([]);
// Refresh tour data (applies to both authenticated and public users)
// This ensures newly uploaded photos appear immediately without requiring a page reload
fetchTour(tourId);
if (onSuccess) onSuccess();
// For public users: redirect to landing page after upload (after data refresh)
// For authenticated users: close modal and show updated tour
if (isPublicView) {
onClose();
// Give time for tour data to refresh before redirecting
setTimeout(() => {
localStorage.setItem('fromPublicUpload', 'true');
window.location.href = '/';
}, 1500);
} else {
onClose();
}
} catch (error) {
notify({
title: 'Lỗi',
@@ -130,6 +170,7 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
}
};
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} />
@@ -177,10 +218,19 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
)}
<button
disabled={isUploading || selectedFiles.length === 0}
disabled={isUploading || isProcessing || 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'}
{isUploading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : isProcessing ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Đang xử nh...
</>
) : (
'Xác nhận tải lên'
)}
</button>
</form>
</div>
+3 -2
View File
@@ -9,7 +9,7 @@ interface Comment {
userName: string;
content: string;
createdAt: string;
userId: string;
userId?: string;
}
interface CommentModalProps {
@@ -81,7 +81,8 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
id: newCommentData.id,
userName: newCommentData.user?.name || 'Ẩn danh',
content: newCommentData.content,
createdAt: newCommentData.createdAt
createdAt: newCommentData.createdAt,
userId: newCommentData.userId || newCommentData.user?.id
}];
});
}
@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import { X, MapPin } from 'lucide-react';
import { X, MapPin, Search, Loader2 } from 'lucide-react';
import { MapContainer, TileLayer, Marker, useMap, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTranslation } from '../hooks/useTranslation';
// Fix Leaflet default marker icon bug
const DefaultIcon = L.icon({
@@ -55,10 +56,16 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
initialLng,
onSelect
}) => {
const { t } = useTranslation();
const defaultCenter: [number, number] = [10.7769, 106.7009]; // TP.HCM default
const [position, setPosition] = useState<[number, number]>(defaultCenter);
const [hasSelected, setHasSelected] = useState(false);
// Search States
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
if (isOpen) {
if (typeof initialLat === 'number' && typeof initialLng === 'number' && !isNaN(initialLat) && !isNaN(initialLng)) {
@@ -68,6 +75,8 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
setPosition(defaultCenter);
setHasSelected(false);
}
setSearchQuery('');
setSearchResults([]);
}
}, [isOpen, initialLat, initialLng]);
@@ -83,6 +92,31 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
onClose();
};
const handleSearch = async () => {
if (!searchQuery.trim()) return;
setIsSearching(true);
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&accept-language=vi&limit=5`);
if (res.ok) {
const data = await res.json();
setSearchResults(data);
}
} catch (e) {
console.error('Error during Nominatim search:', e);
} finally {
setIsSearching(false);
}
};
const handleSelectResult = (place: any) => {
const lat = parseFloat(place.lat);
const lng = parseFloat(place.lon);
setPosition([lat, lng]);
setHasSelected(true);
setSearchResults([]);
setSearchQuery(place.display_name);
};
return (
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4 animate-in fade-in duration-200">
{/* Backdrop */}
@@ -92,33 +126,75 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
/>
{/* Content */}
<div className="relative w-full max-w-2xl h-[550px] bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col border border-gray-100 animate-in zoom-in-95 duration-200">
<div className="relative w-full max-w-2xl h-[550px] bg-white dark:bg-slate-900 rounded-3xl shadow-2xl overflow-hidden flex flex-col border border-gray-100 dark:border-slate-800 animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="p-4 border-b border-gray-100 flex items-center justify-between bg-white shrink-0">
<div className="p-4 border-b border-gray-100 dark:border-slate-800/80 flex items-center justify-between bg-white dark:bg-slate-900 shrink-0">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-blue-500" />
<div className="text-left">
<h3 className="font-extrabold text-sm text-gray-900">Chọn vị trí trên bản đ</h3>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Click lên bản đ đ chọn tọa đ</p>
<h3 className="font-extrabold text-sm text-gray-900 dark:text-white">{t('chooseLocationMap')}</h3>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">{t('clickMapSelectCoords')}</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 hover:bg-gray-100 rounded-full transition-all text-gray-400 hover:text-gray-600"
className="p-1.5 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full transition-all text-gray-400 hover:text-gray-650"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Map Body */}
<div className="flex-1 bg-gray-50 relative min-h-[300px]" style={{ zIndex: 10 }}>
<div className="flex-1 bg-gray-50 dark:bg-slate-950 relative min-h-[300px]" style={{ zIndex: 10 }}>
{/* Floating Search Panel */}
<div className="absolute top-4 left-4 right-4 sm:right-auto z-[1000] sm:w-80 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md rounded-2xl border border-slate-150 dark:border-slate-800 shadow-xl p-2 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<div className="flex-1 relative flex items-center">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder={t('searchPlaceholder')}
className="w-full bg-slate-50 dark:bg-slate-800 border-0 outline-none rounded-xl pl-8 pr-3 py-2 text-xs text-slate-800 dark:text-slate-100"
/>
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5" />
</div>
<button
type="button"
onClick={handleSearch}
disabled={isSearching}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-bold px-3 py-2 rounded-xl text-xs transition-all active:scale-95 shrink-0 flex items-center gap-1"
>
{isSearching ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : t('confirm')}
</button>
</div>
{searchResults.length > 0 && (
<div className="max-h-48 overflow-y-auto divide-y divide-gray-100 dark:divide-slate-800/50 bg-white dark:bg-slate-900 rounded-xl border border-slate-150 dark:border-slate-800 shadow-inner">
{searchResults.map((r, i) => (
<button
key={i}
type="button"
onClick={() => handleSelectResult(r)}
className="w-full text-left px-3 py-2.5 text-[10px] text-gray-700 dark:text-slate-350 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors truncate block"
title={r.display_name}
>
{r.display_name}
</button>
))}
</div>
)}
</div>
<MapContainer
center={position}
zoom={13}
attributionControl={false}
style={{ width: '100%', height: '100%', zIndex: 1 }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapClickEvents onClick={handleMapClick} />
@@ -131,29 +207,29 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-100 flex items-center justify-between bg-white shrink-0">
<div className="text-xs text-gray-500">
<div className="p-4 border-t border-gray-100 dark:border-slate-800/80 flex items-center justify-between bg-white dark:bg-slate-900 shrink-0">
<div className="text-xs text-gray-500 dark:text-slate-400">
{hasSelected ? (
<span className="font-semibold text-gray-700">
Tọa đ: {position[0].toFixed(6)}, {position[1].toFixed(6)}
<span className="font-semibold text-gray-700 dark:text-slate-200">
{t('coordsLabel')}: {position[0].toFixed(6)}, {position[1].toFixed(6)}
</span>
) : (
<span className="italic text-gray-400">Chưa chọn vị trí</span>
<span className="italic text-gray-400 dark:text-slate-500">{t('noCoordsSelected')}</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-200 hover:bg-gray-50 text-gray-700 rounded-xl text-xs font-bold transition-all"
className="px-4 py-2 border border-gray-200 dark:border-slate-800 hover:bg-gray-50 dark:hover:bg-slate-800 text-gray-700 dark:text-slate-300 rounded-xl text-xs font-bold transition-all animate-fade-in"
>
Hủy
{t('cancel')}
</button>
<button
onClick={handleConfirm}
disabled={!hasSelected}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-blue-500/10"
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-200 disabled:text-gray-400 dark:disabled:bg-slate-800 dark:disabled:text-slate-650 disabled:cursor-not-allowed text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-blue-500/10"
>
Xác nhận
{t('confirm')}
</button>
</div>
</div>
+78 -50
View File
@@ -73,13 +73,19 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
setIsLoading(true);
setError('');
try {
const memberIds = members.map((m) => m.id);
const membersPayload = members.map((m) => {
if (m.isManual) {
return { displayName: m.name };
} else {
return { userId: m.id };
}
});
const tour = await createTour({
title,
description,
startDate,
endDate,
memberIds,
members: membersPayload,
adultCount,
childCount,
childDiscount,
@@ -97,18 +103,18 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
return (
<div className="fixed inset-0 z-[2000] 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-md bg-white rounded-3xl shadow-2xl overflow-hidden p-6">
<div className="flex justify-between items-center mb-4">
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h2 className="text-xl font-bold text-gray-900">Tạo Tour mới</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors"></button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="p-5 space-y-4 overflow-y-auto flex-1">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label>
<input
required
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"
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 text-sm"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="VD: Khám phá Đà Lạt"
@@ -170,7 +176,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label>
<input
type="date"
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"
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 text-sm"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
@@ -179,7 +185,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
<label className="block text-sm font-bold text-gray-700 mb-1">Kết thúc</label>
<input
type="date"
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"
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 text-sm"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
@@ -211,49 +217,71 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
<p className="text-[10px] text-blue-400 italic font-medium">* Dùng đ tính toán đơn giá bình quân trong báo cáo chi phí.</p>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label>
<div className="flex flex-wrap gap-3">
{members.map((m) => (
<div key={m.id} className="relative">
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
{m.name}
</div>
<button
type="button"
onClick={() => removeMember(m.id)}
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors"
aria-label="Remove item"
>
<Trash2 size={12} />
</button>
<div className="text-[10px] text-center mt-1 max-w-[70px] truncate">{m.name}</div>
</div>
))}
<div className="relative">
<input
className="w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none"
placeholder="Tìm email..."
value={query}
onChange={(e) => searchUsers(e.target.value)}
/>
{results.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto">
{results.map((u) => (
<button
key={u.id}
type="button"
onClick={() => confirmAddMember(u)}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50"
>
<span className="font-bold text-gray-900">{u.name}</span>
<span className="block text-xs text-gray-500">{u.email}</span>
</button>
))}
</div>
)}
<div className="space-y-2">
<label className="block text-sm font-bold text-gray-700">Thành viên tham gia ({members.length})</label>
{members.length > 0 && (
<div className="flex flex-wrap gap-3 mb-3 p-3 bg-gray-50 rounded-2xl border border-gray-100">
{members.map((m) => {
const initial = m.name?.charAt(0) || '?';
return (
<div key={m.id} className="flex flex-col items-center gap-1">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
{initial}
</div>
<button
type="button"
onClick={() => removeMember(m.id)}
className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
aria-label="Remove item"
>
<Trash2 size={10} />
</button>
</div>
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{m.name}</span>
</div>
);
})}
</div>
)}
<div className="relative">
<input
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 text-sm font-bold text-gray-800"
placeholder="Tìm email hoặc nhập tên thành viên ngoài hệ thống..."
value={query}
onChange={(e) => searchUsers(e.target.value)}
/>
{(results.length > 0 || query.trim()) && (
<div className="absolute bottom-full mb-2 left-0 right-0 bg-white border border-gray-100 rounded-2xl shadow-xl z-20 max-h-48 overflow-y-auto p-2 space-y-1">
{query.trim() && (
<button
type="button"
onClick={() => {
const name = query.trim();
setMembers((prev) => (prev.some((m) => m.name.toLowerCase() === name.toLowerCase()) ? prev : [...prev, { id: `manual-${Date.now()}`, name, isManual: true }]));
setQuery('');
setResults([]);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 rounded-xl text-blue-600 font-bold flex items-center gap-2"
>
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-600 text-sm font-black">+</span>
<span>Thêm thành viên ngoài hệ thống: "{query.trim()}"</span>
</button>
)}
{results.filter(u => !members.some(m => m.id === u.id)).map((u) => (
<button
key={u.id}
type="button"
onClick={() => confirmAddMember(u)}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 rounded-xl flex flex-col"
>
<span className="font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</span>
<span className="text-xs text-gray-500">{u.email}</span>
</button>
))}
</div>
)}
</div>
{error && <p className="text-xs text-red-600 mt-2">{error}</p>}
</div>
File diff suppressed because one or more lines are too long
+45 -21
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare, FileText, Flag } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
@@ -63,7 +63,6 @@ export const ItineraryTimeline = ({
// Removed: const { isPublicView } = useTourStore(state => state); // isPublicView is passed as a prop
const deleteLeg = useTourStore(state => state.deleteLeg);
const initializeLegs = useTourStore(state => state.initializeLegs);
const fetchTour = useTourStore(state => state.fetchTour);
const deleteLocation = useTourStore(state => state.deleteLocation);
// Khai báo logic canEdit để sử dụng trong toàn bộ component
@@ -80,9 +79,9 @@ export const ItineraryTimeline = ({
// Tối ưu hóa: Cập nhật UI ngay lập tức bằng cách can thiệp vào State của Store
const handleCommentIncrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map(leg => ({
const updatedLegs = currentLegs.map((leg: any) => ({
...leg,
locations: leg.locations.map(loc =>
locations: leg.locations.map((loc: any) =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
: loc
@@ -94,9 +93,9 @@ export const ItineraryTimeline = ({
const handleCommentDecrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map(leg => ({
const updatedLegs = currentLegs.map((leg: any) => ({
...leg,
locations: leg.locations.map(loc =>
locations: leg.locations.map((loc: any) =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
: loc
@@ -201,7 +200,7 @@ export const ItineraryTimeline = ({
}, [legs]);
return (
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
<div id="itinerary-timeline-print-zone" className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
<div className="px-2 pt-4">
{legs.length === 0 ? (
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
@@ -250,7 +249,7 @@ export const ItineraryTimeline = ({
{canEdit && (
<>
<button
onClick={() => onAddLocation?.(leg.id)}
onClick={() => onAddLocation?.(leg.id, legIdx === 0 && leg.locations.length === 0)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
title="Thêm địa điểm vào chặng này"
>
@@ -305,7 +304,7 @@ export const ItineraryTimeline = ({
<div className="ml-2">
{/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
{legIdx === 0 && !leg.locations.some(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
{legIdx === 0 && !leg.locations.some((loc: any) => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
<div className="z-10 mt-1.5 mr-4">
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-blue-200 flex items-center justify-center text-blue-400">
@@ -326,7 +325,7 @@ export const ItineraryTimeline = ({
)}
{/* Nút thêm nhanh "Điểm kết thúc" cho Chặng cuối nếu chưa có */}
{legIdx === legs.length - 1 && !legs.some(l => l.locations.some(loc => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0)) && (
{legIdx === legs.length - 1 && !legs.some((l: any) => l.locations.some((loc: any) => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0)) && (
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
<div className="z-10 mt-1.5 mr-4">
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-red-200 flex items-center justify-center text-red-400">
@@ -346,9 +345,9 @@ export const ItineraryTimeline = ({
</div>
)}
{leg.locations.map((location, idx) => {
{leg.locations.map((location: any) => {
// Tìm vị trí của điểm này trong toàn bộ hành trình
const globalIdx = allLocations.findIndex(loc => loc.id === location.id);
const globalIdx = allLocations.findIndex((loc: any) => loc.id === location.id);
const prevLocation = globalIdx > 0 ? allLocations[globalIdx - 1] : null;
const distanceFromPrev = prevLocation
@@ -386,9 +385,12 @@ export const ItineraryTimeline = ({
</div>
{/* Card Content */}
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
}`}>
<div
onClick={() => onNavigate?.(location)}
className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 cursor-pointer ${
location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
}`}
>
<div className="flex justify-between items-start">
<div>
{isStartPoint && (
@@ -397,7 +399,13 @@ export const ItineraryTimeline = ({
{isEndPoint && (
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
)}
<h3 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
<h3
onClick={(e) => {
e.stopPropagation();
onNavigate?.(location);
}}
className={`font-semibold text-lg cursor-pointer hover:text-blue-600 transition-colors select-none ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}
>
{location.name}
</h3>
<div className="flex items-center text-sm text-gray-500 mt-1">
@@ -434,11 +442,14 @@ export const ItineraryTimeline = ({
)}
</div>
<div className="text-right flex flex-col items-end">
<div className="text-right flex flex-col items-end" onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1 mb-2">
{onQuickNote && !isPublicView && (
<button
onClick={() => onQuickNote(location.name)}
onClick={(e) => {
e.stopPropagation();
onQuickNote(location.name);
}}
className="flex items-center gap-1 px-2 py-1 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-bold transition-all border border-amber-100"
title="Ghi chú nhanh"
>
@@ -446,7 +457,8 @@ export const ItineraryTimeline = ({
</button>
)}
<button
onClick={() => {
onClick={(e) => {
e.stopPropagation();
setCommentLocationId(location.id);
setCommentLocationName(location.name);
setIsCommentModalOpen(true);
@@ -468,10 +480,22 @@ export const ItineraryTimeline = ({
)}
{canEdit && ( // Allow editing and deleting of all locations if user has edit permissions
<div className="flex gap-1 mt-2">
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
<button
onClick={(e) => {
e.stopPropagation();
onEditLocation?.(location);
}}
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleDeleteLocation(location.id)} className="p-1 text-gray-400 hover:text-red-600 transition-colors">
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteLocation(location.id);
}}
className="p-1 text-gray-400 hover:text-red-600 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
@@ -0,0 +1,330 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Mail, Lock, ArrowRight, Loader2, LogIn } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
interface JoinTourLoginModalProps {
isOpen: boolean;
onClose: () => void;
inviteToken: string;
onJoinSuccess?: (user: any, tourData: any) => void;
onSwitchToSignup?: () => void;
}
export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
isOpen,
onClose,
inviteToken,
onJoinSuccess,
onSwitchToSignup
}) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const notify = useNotification();
// Ref to store the latest inviteToken to avoid stale closure issue
const inviteTokenRef = useRef(inviteToken);
useEffect(() => {
inviteTokenRef.current = inviteToken;
}, [inviteToken]);
const handleGoogleLogin = async (googleResponse: any) => {
setError('');
setIsLoading(true);
try {
const response = await fetch(`/api/v1/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: googleResponse.credential }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập Google thất bại');
}
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
// Use the latest inviteToken from ref
let currentToken = inviteTokenRef.current;
console.log('[JoinTourLogin] Attempting to join with token:', currentToken?.substring(0, 10) + '...');
// Also check pendingInviteToken as fallback
let pendingToken = localStorage.getItem('pendingInviteToken');
// If no token found, try to get from URL
if (!currentToken && !pendingToken) {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
if (urlToken) {
currentToken = urlToken;
localStorage.setItem('pendingInviteToken', urlToken);
console.log('[JoinTourLogin] Using token from URL params:', urlToken.substring(0, 10) + '...');
}
}
const tokenToUse = currentToken || pendingToken;
if (!tokenToUse) {
console.error('[JoinTourLogin] No invite token available');
throw new Error('Không có mã lời mời để tham gia tour');
}
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: tokenToUse }),
});
console.log('[JoinTourLogin] Join response status:', joinRes.status);
const joinData = await joinRes.json().catch(() => ({}));
if (joinRes.ok) {
console.log('[JoinTourLogin] Join successful');
notify({
title: 'Thành công',
message: joinData.message || 'Bạn đã gia nhập tour!',
type: 'success'
});
localStorage.removeItem('pendingInviteToken');
if (onJoinSuccess) {
onJoinSuccess(data.user, joinData);
}
onClose();
} else {
// Token invalid or expired - clear it and show error
console.log('[JoinTourLogin] Token invalid, clearing from storage');
localStorage.removeItem('pendingInviteToken');
// Email mismatch or other error
const errorMessage = joinData.message || `Không thể gia nhập tour (HTTP ${joinRes.status})`;
console.error('[JoinTourLogin] Join failed:', errorMessage);
setError(`Lỗi gia nhập tour: ${errorMessage}`);
setIsLoading(false);
}
} catch (e: any) {
console.error('[JoinTourLogin] Join exception:', e);
setError(`Lỗi khi gia nhập: ${e.message}`);
setIsLoading(false);
}
} catch (err: any) {
console.error('[JoinTourLogin] Google login error:', err);
setError(err.message);
setIsLoading(false);
}
};
const handleEmailPasswordJoin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const response = await fetch(`/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập thất bại');
}
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
// Use the latest inviteToken from ref
const tokenToUse = inviteTokenRef.current || localStorage.getItem('pendingInviteToken');
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: tokenToUse }),
});
const joinData = await joinRes.json().catch(() => ({}));
if (joinRes.ok) {
notify({
title: 'Thành công',
message: joinData.message || 'Bạn đã gia nhập tour!',
type: 'success'
});
localStorage.removeItem('pendingInviteToken');
if (onJoinSuccess) {
onJoinSuccess(data.user, joinData);
}
onClose();
} else {
const errorMessage = joinData.message || 'Không thể gia nhập tour';
setError(`Lỗi gia nhập tour: ${errorMessage}`);
setIsLoading(false);
}
} catch (e: any) {
setError(`Lỗi khi gia nhập: ${e.message}`);
setIsLoading(false);
}
} catch (err: any) {
console.error('[JoinTourLogin] Login error:', err);
setError(err.message);
setIsLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
const timer = setTimeout(() => {
if (typeof window !== 'undefined' && (window as any).google) {
try {
(window as any).google.accounts.id.initialize({
client_id: (import.meta as any).env.VITE_GOOGLE_CLIENT_ID || '864264639911-dummyid.apps.googleusercontent.com',
callback: handleGoogleLogin,
});
(window as any).google.accounts.id.renderButton(
document.getElementById('google-signin-btn-join-tour'),
{ theme: 'outline', size: 'large', width: '380' }
);
} catch (e) {
console.error('Lỗi khởi tạo Google Sign-in:', e);
}
}
}, 100);
return () => clearTimeout(timer);
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/50">
<div className="w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="relative h-32 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 overflow-hidden">
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_50%,rgba(255,255,255,.3)_0%,transparent_50%)]" />
</div>
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 p-2 hover:bg-white/20 rounded-full transition-colors"
>
<X className="w-5 h-5 text-white" />
</button>
<div className="absolute inset-0 flex items-center justify-center">
<LogIn className="w-12 h-12 text-white opacity-80" />
</div>
</div>
{/* Content */}
<div className="p-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2 text-center">
Gia nhập tour
</h2>
<p className="text-center text-gray-600 mb-6">
Đăng nhập đ tham gia chuyến du lịch này
</p>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
{/* Google OAuth Button */}
<div className="mb-6 flex justify-center">
<div id="google-signin-btn-join-tour" className="w-full" />
</div>
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Hoặc</span>
</div>
</div>
{/* Email/Password Form */}
<form onSubmit={handleEmailPasswordJoin} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-3.5 w-5 h-5 text-gray-400" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Mật khẩu
</label>
<div className="relative">
<Lock className="absolute left-3 top-3.5 w-5 h-5 text-gray-400" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Nhập mật khẩu"
required
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-blue-500 to-purple-500 text-white font-semibold py-3 rounded-lg hover:shadow-lg transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Đang gia nhập...
</>
) : (
<>
<ArrowRight className="w-5 h-5" />
Gia nhập tour
</>
)}
</button>
</form>
{/* Signup Link */}
<div className="mt-6 text-center text-sm text-gray-600">
Chưa tài khoản?{' '}
<button
onClick={() => {
onClose();
if (onSwitchToSignup) onSwitchToSignup();
}}
className="font-semibold text-blue-500 hover:text-blue-600 transition"
>
Đăng tại đây
</button>
</div>
</div>
</div>
</div>
);
};
+130 -6
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
interface LoginModalProps {
@@ -14,6 +14,117 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleGoogleLogin = async (googleResponse: any) => {
setError('');
setIsLoading(true);
try {
const response = await fetch(`/api/v1/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: googleResponse.credential }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập Google thất bại');
}
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
// Remove guest tokens to ensure clean real user session
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
console.log('[OAuth] Google login successful');
// Check if there's a pending invite token to join tour
// Try localStorage first, then fallback to URL parameter
let pendingInviteToken = localStorage.getItem('pendingInviteToken');
console.log('[OAuth] pendingInviteToken from localStorage:', pendingInviteToken ? pendingInviteToken.substring(0, 20) + '...' : 'none');
// If no token in localStorage, try to get from URL (in case of race condition)
if (!pendingInviteToken) {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
if (urlToken) {
pendingInviteToken = urlToken;
localStorage.setItem('pendingInviteToken', urlToken);
console.log('[OAuth] Using token from URL params:', urlToken.substring(0, 20) + '...');
}
}
if (pendingInviteToken) {
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: pendingInviteToken }),
});
const joinData = await joinRes.json().catch(() => ({}));
if (joinRes.ok) {
localStorage.removeItem('pendingInviteToken');
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
} else {
// If invitation is invalid/expired, clear it and redirect to dashboard
console.log('[OAuth] Token invalid or expired, clearing and redirecting to dashboard');
localStorage.removeItem('pendingInviteToken');
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
}
} catch (joinErr: any) {
console.error('[OAuth] Join tour after login failed:', joinErr);
localStorage.removeItem('pendingInviteToken');
// Still redirect to dashboard on error
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
}
} else {
// Regular login - no auto-join for tour
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
}
} catch (err: any) {
console.error('[OAuth] Login error:', err);
setError(err.message);
setIsLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
const timer = setTimeout(() => {
if (typeof window !== 'undefined' && (window as any).google) {
try {
(window as any).google.accounts.id.initialize({
client_id: (import.meta as any).env.VITE_GOOGLE_CLIENT_ID || '864264639911-dummyid.apps.googleusercontent.com',
callback: handleGoogleLogin,
});
(window as any).google.accounts.id.renderButton(
document.getElementById('google-signin-btn-login'),
{ theme: 'outline', size: 'large', width: '380' }
);
} catch (e) {
console.error('Lỗi khởi tạo Google Sign-in:', e);
}
}
}, 100);
return () => clearTimeout(timer);
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
@@ -36,7 +147,11 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
// Lưu phiên đăng nhập
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
console.log('[LoginModal] Email/password login successful');
// Regular login - no auto-join for tour
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
@@ -57,7 +172,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
/>
{/* Modal Content */}
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-300">
<div className="p-8 sm:p-10">
<div className="flex justify-between items-start mb-8">
<div>
@@ -80,12 +195,12 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-semibold text-gray-700 ml-1">Email</label>
<label className="text-sm font-semibold text-gray-700 ml-1">Tài khoản hoặc Email</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="email"
placeholder="name@example.com"
type="text"
placeholder="admin hoặc email..."
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@@ -122,6 +237,15 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
</button>
</form>
<div className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200"></div>
</div>
<span className="relative px-3 bg-white text-xs font-bold text-gray-400 uppercase">Hoặc</span>
</div>
<div id="google-signin-btn-login" className="w-full flex justify-center"></div>
<div className="mt-10 pt-8 border-t border-gray-100 text-center">
<p className="text-gray-500">
Chưa tài khoản?{' '}
+510
View File
@@ -0,0 +1,510 @@
import React, { useState, useMemo } from 'react';
import {
Users, User, Shield, ShieldAlert, ShieldCheck, Mail, Trash2,
Clock, Check, X, GitMerge, ArrowRight, Search, Plus, Sparkles
} from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
interface MembersTabProps {
tourId: string;
participants: any[];
joinRequests: any[];
userRole: string | null;
canManage: boolean;
isOwner: boolean;
onRemoveMember: (memberIdOrUserId: string) => Promise<void>;
onRefresh: () => void;
onOpenAddMember?: () => void;
}
export const MembersTab: React.FC<MembersTabProps> = ({
tourId,
participants,
joinRequests: initialJoinRequests,
canManage,
isOwner,
onRemoveMember,
onRefresh,
onOpenAddMember
}) => {
const confirm = useConfirm();
const notify = useNotification();
const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest);
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
const [joinRequests, setJoinRequests] = useState<any[]>(initialJoinRequests);
const [mergingId, setMergingId] = useState<string | null>(null);
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
const [assigningManualMember, setAssigningManualMember] = useState<any | null>(null);
const [systemSearchQuery, setSystemSearchQuery] = useState('');
// Sync state with props
React.useEffect(() => {
setJoinRequests(initialJoinRequests);
}, [initialJoinRequests]);
// Separate system vs manual members
const systemMembers = useMemo(() => {
return participants.filter((p: any) => p.userId && p.user);
}, [participants]);
const manualMembers = useMemo(() => {
return participants.filter((p: any) => !p.userId && p.displayName);
}, [participants]);
// Auto-detect duplicate matches based on case-insensitive names
const duplicateMatches = useMemo(() => {
const matches: Array<{ manual: any; system: any }> = [];
manualMembers.forEach((m: any) => {
const match = systemMembers.find((s: any) => {
return s.user.name.trim().toLowerCase() === m.displayName.trim().toLowerCase();
});
if (match) {
matches.push({ manual: m, system: match });
}
});
return matches;
}, [systemMembers, manualMembers]);
// Filter system members for manual merge modal
const filteredSystemMembersForMerge = useMemo(() => {
if (!systemSearchQuery.trim()) return systemMembers;
return systemMembers.filter((s: any) =>
s.user.name.toLowerCase().includes(systemSearchQuery.toLowerCase()) ||
(s.user.email && s.user.email.toLowerCase().includes(systemSearchQuery.toLowerCase()))
);
}, [systemMembers, systemSearchQuery]);
// Handle merging logic
const handleMerge = async (manualParticipantId: string, systemUserId: string, manualName: string, systemName: string) => {
const isConfirmed = await confirm({
title: 'Hợp nhất thành viên',
message: `Bạn có chắc muốn hợp nhất thành viên thủ công "${manualName}" vào tài khoản "${systemName}"? Bản ghi thủ công sẽ bị xóa và các dữ liệu liên quan sẽ được gộp.`
});
if (!isConfirmed) return;
setMergingId(manualParticipantId);
try {
const res = await fetch(`/api/v1/tours/${tourId}/members/merge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
manualParticipantId,
systemUserId
})
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'Hợp nhất thất bại.');
}
notify({ title: 'Thành công', message: 'Hợp nhất thành viên thành công!', type: 'success' });
setAssigningManualMember(null);
onRefresh();
} catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể hợp nhất', type: 'error' });
} finally {
setMergingId(null);
}
};
const getRoleBadge = (role: string) => {
switch (role) {
case 'OWNER':
return (
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-red-50 text-red-700 border border-red-100 text-[10px] font-bold">
<ShieldAlert className="w-3 h-3 text-red-500" /> Trưởng đoàn
</span>
);
case 'MANAGER':
return (
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-blue-50 text-blue-700 border border-blue-100 text-[10px] font-bold">
<ShieldCheck className="w-3 h-3 text-blue-500" /> Phó đoàn
</span>
);
case 'MEMBER':
return (
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-green-50 text-green-700 border border-green-100 text-[10px] font-bold">
<Shield className="w-3 h-3 text-green-500" /> Thành viên
</span>
);
case 'MEMBER_NO_FINANCE':
return (
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-gray-50 text-gray-600 border border-gray-100 text-[10px] font-bold">
<Shield className="w-3 h-3 text-gray-400" /> Thành viên (Không xem quỹ)
</span>
);
default:
return (
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-gray-100 text-gray-600 text-[10px] font-semibold">
{role}
</span>
);
}
};
// Get current user id to prevent self-deletion
const getCurrentUserId = () => {
const rawToken = localStorage.getItem('token');
if (!rawToken) return null;
try {
const payload = JSON.parse(atob(rawToken.split('.')[1]));
return payload.sub;
} catch {
return null;
}
};
const currentUserId = getCurrentUserId();
return (
<div className="space-y-6">
{/* Header and Actions */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-white p-5 rounded-3xl border border-gray-100 shadow-sm animate-in fade-in duration-300">
<div>
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
<Users className="w-6 h-6 text-indigo-600" /> Quản thành viên
</h2>
<p className="text-xs text-gray-500 mt-1">
Quản thành viên hệ thống, thành viên thủ công các yêu cầu tham gia chuyến đi.
</p>
</div>
{canManage && onOpenAddMember && (
<button
onClick={onOpenAddMember}
className="flex items-center gap-1.5 px-4 py-2.5 rounded-2xl bg-indigo-600 hover:bg-indigo-700 active:scale-95 text-white font-bold text-xs transition-all shadow-md shadow-indigo-100"
>
<Plus className="w-4 h-4" />
Thêm & Mời thành viên
</button>
)}
</div>
{/* Auto-detect duplicates alert */}
{duplicateMatches.length > 0 && (
<div className="p-5 bg-amber-50 rounded-3xl border border-amber-200/60 text-amber-900 space-y-3 shadow-sm animate-in slide-in-from-top-4 duration-300">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-amber-500 animate-pulse" />
<h4 className="font-bold text-sm">Phát hiện trùng lặp tự đng</h4>
</div>
<p className="text-xs text-amber-700">
Hệ thống phát hiện thành viên đưc tạo thủ công trùng tên với tài khoản hệ thống mới gia nhập. Bạn nên gộp họ lại đ đng bộ thông tin chặng đi chi phí.
</p>
<div className="space-y-2 mt-2">
{duplicateMatches.map((match) => (
<div
key={match.manual.id}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3 bg-white rounded-2xl border border-amber-200 shadow-sm text-xs"
>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-gray-800">Thành viên thủ công: "{match.manual.displayName}"</span>
<ArrowRight className="w-3.5 h-3.5 text-amber-500" />
<span className="font-bold text-indigo-700">Tài khoản hệ thống: "{match.system.user.name}"</span>
</div>
<button
disabled={mergingId === match.manual.id}
onClick={() => handleMerge(match.manual.id, match.system.userId, match.manual.displayName, match.system.user.name)}
className="px-3.5 py-1.5 bg-amber-500 hover:bg-amber-600 disabled:bg-amber-300 text-white font-bold rounded-xl transition-all text-[11px] self-end sm:self-auto flex items-center gap-1"
>
<GitMerge className="w-3.5 h-3.5" />
{mergingId === match.manual.id ? 'Đang xử lý...' : 'Gán & Hợp nhất'}
</button>
</div>
))}
</div>
</div>
)}
{/* Pending requests */}
{joinRequests.length > 0 && (
<div className="p-5 bg-white rounded-3xl border border-gray-100 shadow-sm space-y-4 animate-in fade-in duration-300">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-indigo-500 animate-pulse" />
<h3 className="text-sm font-bold text-gray-900">Yêu cầu tham gia chờ duyệt</h3>
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">{joinRequests.length}</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{joinRequests.map((req: any) => (
<div key={req.id} className="flex items-center justify-between gap-3 p-3.5 rounded-2xl border border-gray-100 bg-gray-50/50">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-indigo-50 border border-indigo-100 flex items-center justify-center text-indigo-700 font-bold text-sm">
{req.user?.name?.charAt(0) || '?'}
</div>
<div>
<div className="text-xs font-bold text-gray-800">{req.user?.name || req.userId}</div>
<div className="text-[10px] text-gray-500 mt-0.5">
{req.user?.email}
</div>
</div>
</div>
{isOwner && (
<div className="flex gap-1.5 shrink-0">
<button
disabled={joinRequestActionId === req.id}
onClick={async () => {
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?`
});
if (!isConfirmed) return;
setJoinRequestActionId(req.id);
try {
await acceptJoinRequest(tourId, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
onRefresh();
} catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
} finally {
setJoinRequestActionId(null);
}
}}
className="p-1.5 rounded-xl bg-green-50 hover:bg-green-100 text-green-700 transition-colors disabled:opacity-50"
title="Chấp nhận"
>
<Check className="w-4 h-4" />
</button>
<button
disabled={joinRequestActionId === req.id}
onClick={async () => {
const isConfirmed = await confirm({
title: 'Từ chối yêu cầu',
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
});
if (!isConfirmed) return;
setJoinRequestActionId(req.id);
try {
await rejectJoinRequest(tourId, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
onRefresh();
} catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
} finally {
setJoinRequestActionId(null);
}
}}
className="p-1.5 rounded-xl bg-red-50 hover:bg-red-100 text-red-700 transition-colors disabled:opacity-50"
title="Từ chối"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Main Members Grid */}
<div className="grid gap-6 md:grid-cols-2">
{/* System Accounts */}
<div className="p-5 bg-white rounded-3xl border border-gray-100 shadow-sm space-y-4 flex flex-col">
<div className="flex justify-between items-center">
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
Thành viên hệ thống ({systemMembers.length})
</h3>
</div>
<div className="space-y-2.5 flex-1 max-h-[400px] overflow-y-auto pr-1">
{systemMembers.map((member: any) => {
const isCurrentUser = currentUserId && member.userId === currentUserId;
const isMemberOwner = member.role === 'OWNER';
const canRemove = canManage && !isCurrentUser && !isMemberOwner;
return (
<div key={member.id} className="flex items-center justify-between p-3 bg-gray-50/50 rounded-2xl border border-gray-100/50 hover:border-gray-200 transition-all">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-indigo-50 border border-indigo-100 flex items-center justify-center text-indigo-700 font-bold text-sm">
{member.user.name.charAt(0)}
</div>
<div>
<div className="text-xs font-bold text-gray-800 flex items-center gap-1.5">
{member.user.name}
{isCurrentUser && <span className="text-[9px] bg-indigo-100 text-indigo-700 font-black px-1.5 py-0.5 rounded-md">Tôi</span>}
</div>
<div className="text-[10px] text-gray-500 flex items-center gap-1 mt-0.5">
<Mail className="w-3 h-3 text-gray-400" />
{member.user.email}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{getRoleBadge(member.role)}
{canRemove && (
<button
onClick={async () => {
const isConfirmed = await confirm({
title: 'Xóa thành viên',
message: `Bạn có chắc chắn muốn xóa thành viên "${member.user.name}" khỏi hành trình?`
});
if (isConfirmed) {
try {
await onRemoveMember(member.userId);
notify({ title: 'Thành công', message: 'Đã xóa thành viên', type: 'success' });
} catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể xóa thành viên', type: 'error' });
}
}
}}
className="p-1.5 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded-xl transition-all"
title="Xóa thành viên"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Manual Members */}
<div className="p-5 bg-white rounded-3xl border border-gray-100 shadow-sm space-y-4 flex flex-col">
<div className="flex justify-between items-center">
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-500"></span>
Thành viên thủ công ({manualMembers.length})
</h3>
</div>
<div className="space-y-2.5 flex-1 max-h-[400px] overflow-y-auto pr-1">
{manualMembers.map((member: any) => {
const canRemove = canManage;
return (
<div key={member.id} className="flex items-center justify-between p-3 bg-gray-50/50 rounded-2xl border border-gray-100/50 hover:border-gray-200 transition-all">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-50 border border-amber-100 flex items-center justify-center text-amber-700 font-bold text-sm">
{member.displayName.charAt(0)}
</div>
<div>
<div className="text-xs font-bold text-gray-800">
{member.displayName}
</div>
<div className="text-[10px] text-gray-400 mt-0.5">
Tạo ngoài hệ thống
</div>
</div>
</div>
<div className="flex items-center gap-2">
{getRoleBadge(member.role)}
{canManage && (
<button
onClick={() => setAssigningManualMember(member)}
className="flex items-center gap-1 px-2.5 py-1 rounded-xl bg-indigo-50 hover:bg-indigo-100 text-indigo-700 text-[10px] font-bold transition-all border border-indigo-100"
title="Hợp nhất với tài khoản hệ thống"
>
<GitMerge className="w-3 h-3" />
Gán tài khoản
</button>
)}
{canRemove && (
<button
onClick={async () => {
const isConfirmed = await confirm({
title: 'Xóa thành viên',
message: `Bạn có chắc chắn muốn xóa thành viên thủ công "${member.displayName}"?`
});
if (isConfirmed) {
try {
await onRemoveMember(member.id);
notify({ title: 'Thành công', message: 'Đã xóa thành viên', type: 'success' });
} catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể xóa thành viên', type: 'error' });
}
}
}}
className="p-1.5 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded-xl transition-all"
title="Xóa thành viên"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
);
})}
{manualMembers.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-center py-10 text-gray-400">
<User className="w-8 h-8 opacity-40 mb-2" />
<span className="text-xs">Chưa thành viên thủ công nào</span>
</div>
)}
</div>
</div>
</div>
{/* Manual Merge Modal Selector */}
{assigningManualMember && (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={() => setAssigningManualMember(null)} />
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200">
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div>
<h3 className="text-md font-bold text-gray-900 flex items-center gap-2">
<GitMerge className="w-5 h-5 text-indigo-600" /> Gán tài khoản hệ thống
</h3>
<p className="text-xs text-gray-500 mt-1">
Chọn một tài khoản hệ thống đ gán cho thành viên thủ công <strong>"{assigningManualMember.displayName}"</strong>.
</p>
</div>
<button
onClick={() => setAssigningManualMember(null)}
className="p-1.5 hover:bg-gray-100 rounded-full transition-colors"
>
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* Search Bar */}
<div className="p-4 border-b border-gray-100">
<div className="relative">
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Tìm tài khoản hệ thống theo tên hoặc email..."
value={systemSearchQuery}
onChange={(e) => setSystemSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-xl text-xs outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 transition-all"
/>
</div>
</div>
{/* System accounts list */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{filteredSystemMembersForMerge.map((systemMember: any) => (
<button
key={systemMember.id}
disabled={mergingId === assigningManualMember.id}
onClick={() => handleMerge(assigningManualMember.id, systemMember.userId, assigningManualMember.displayName, systemMember.user.name)}
className="w-full flex items-center justify-between p-3 hover:bg-indigo-50/50 active:bg-indigo-50 border border-gray-100 hover:border-indigo-100 rounded-2xl text-left transition-all"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-indigo-50 border border-indigo-100 flex items-center justify-center text-indigo-700 font-bold text-xs shrink-0">
{systemMember.user.name.charAt(0)}
</div>
<div className="min-w-0">
<div className="text-xs font-bold text-gray-800 truncate">{systemMember.user.name}</div>
<div className="text-[10px] text-gray-500 truncate mt-0.5">{systemMember.user.email}</div>
</div>
</div>
<div className="shrink-0">
<ArrowRight className="w-4 h-4 text-gray-400" />
</div>
</button>
))}
{filteredSystemMembersForMerge.length === 0 && (
<div className="py-10 text-center text-gray-400 text-xs">
Không tìm thấy tài khoản hệ thống phù hợp.
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
import { CheckCircle, AlertCircle, Info } from 'lucide-react';
interface NotificationModalProps {
isOpen: boolean;
+123 -23
View File
@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart } from 'lucide-react';
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart, Trash2 } from 'lucide-react';
import { io } from 'socket.io-client';
import { CoordinateSelectModal } from './CoordinateSelectModal';
import { useTranslation } from '../hooks/useTranslation';
interface Comment {
id: string;
@@ -46,6 +47,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
onLoginSuccess,
onUpdatePhoto
}) => {
const { t } = useTranslation();
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false);
@@ -62,6 +64,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
useEffect(() => {
const lat = photo?.metadata?.lat;
@@ -260,6 +263,12 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
}
});
socket.on('photoCommentDeleted', (deleted: any) => {
if (deleted.photoId === photo.id) {
setComments(prev => prev.filter(c => c.id !== deleted.id));
}
});
return () => {
socket.disconnect();
};
@@ -348,6 +357,28 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
}
};
const handleDeleteComment = async (commentId: string) => {
if (!confirm(t('confirmDeleteComment') || 'Bạn có chắc chắn muốn xóa bình luận này không?')) return;
try {
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
const res = await fetch(`/api/v1/public-photos/comments/${commentId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (res.ok) {
setComments(prev => prev.filter(c => c.id !== commentId));
} else {
const err = await res.json();
alert(err.message || 'Lỗi khi xóa bình luận.');
}
} catch (error) {
console.error('Lỗi khi xóa bình luận:', error);
alert('Không thể kết nối đến máy chủ.');
}
};
const isAuthorized = currentUser?.isAdmin ||
(currentUser && photo.uploader && currentUser.id === photo.uploader.id) ||
(currentUser && photo.uploaderId && currentUser.id === photo.uploaderId);
@@ -359,15 +390,24 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300"
onClick={onClose}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
/>
{/* Container */}
<div className="fixed inset-0 overflow-y-auto flex flex-col bg-slate-900 text-slate-100 md:relative md:inset-auto md:overflow-hidden md:rounded-[32px] md:shadow-2xl md:border md:border-slate-800 md:w-full md:max-w-5xl md:h-[85vh] md:flex-row animate-in zoom-in-95 duration-300">
<div
className="fixed inset-0 overflow-y-auto flex flex-col bg-slate-900 text-slate-100 md:relative md:inset-auto md:overflow-hidden md:rounded-[32px] md:shadow-2xl md:border md:border-slate-800 md:w-full md:max-w-5xl md:h-[85vh] md:flex-row animate-in zoom-in-95 duration-300"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button Mobile/Desktop */}
<button
onClick={onClose}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="fixed md:absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 md:top-4 md:right-4 z-50 p-2 bg-slate-950/60 hover:bg-slate-800/80 border border-slate-700/50 rounded-full text-slate-300 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
@@ -390,7 +430,10 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
{/* Like Button Overlay */}
<button
onClick={handleToggleLike}
onClick={(e) => {
e.stopPropagation();
handleToggleLike();
}}
className="absolute bottom-4 right-4 z-40 flex items-center gap-1.5 bg-slate-950/70 hover:bg-slate-900/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md md:absolute md:top-[calc(1rem+env(safe-area-inset-top,0px))] md:left-4 md:bottom-auto md:right-auto"
title={isLiked ? "Bỏ thích" : "Thích"}
>
@@ -402,7 +445,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<a
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center"
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center relative"
onClick={(e) => {
e.preventDefault();
setIsFullscreen(true);
@@ -411,33 +454,53 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<img
src={photo.imageUrl}
alt="Public Map Upload"
className="w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none select-none ${
!isLoggedIn ? 'pointer-events-none' : ''
}`}
draggable={false}
/>
{/* Shield overlay to prevent Save Image As on right-click / long press for non-owners */}
{(!isAuthorized || !isLoggedIn) && (
<div className="absolute inset-0 bg-transparent select-none z-10" />
)}
</a>
</div>
{/* Info & Timeline overlay inside photo panel */}
<div className="relative p-6 bg-slate-900 border-b border-slate-800/60 md:absolute md:bottom-0 md:left-0 md:right-0 md:bg-gradient-to-t md:from-black/60 md:via-black/20 md:to-transparent md:border-b-0 flex flex-col gap-4">
<div className="relative z-20 p-6 bg-slate-900 border-b border-slate-800/60 md:absolute md:bottom-0 md:left-0 md:right-0 md:bg-gradient-to-t md:from-black/60 md:via-black/20 md:to-transparent md:border-b-0 flex flex-col gap-4">
{/* Timeline scroll */}
{photoGroup && photoGroup.length > 1 && (
<div className="flex flex-col gap-2 border-b border-slate-800/80 pb-3">
<div className="flex flex-col gap-2 border-b border-slate-800/80 pb-3 relative z-20">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
Lịch sử nh tại vị trí này ({photoGroup.length})
</span>
<div className="flex gap-3 overflow-x-auto no-scrollbar py-1">
<div className="flex gap-3 overflow-x-auto no-scrollbar py-1 relative z-20">
{photoGroup.map((p) => {
const isActive = p.id === photo.id;
return (
<button
key={p.id}
onClick={() => onSelectPhoto?.(p)}
onClick={(e) => {
e.stopPropagation();
onSelectPhoto?.(p);
}}
className={`relative w-12 h-12 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
isActive ? 'border-emerald-500 scale-110 shadow-lg' : 'border-slate-700 hover:border-slate-500'
}`}
>
<img src={p.imageUrl} alt="Timeline thumbnail" className="w-full h-full object-cover" />
<img
src={p.imageUrl}
alt="Timeline thumbnail"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`w-full h-full object-cover ${
!isLoggedIn ? 'pointer-events-none' : ''
}`}
/>
<div className="absolute bottom-0 inset-x-0 bg-slate-950/70 text-[8px] text-center font-bold text-slate-300 py-0.5">
{new Date(p.capturedAt).toLocaleDateString('vi-VN', { month: '2-digit', day: '2-digit' })}
</div>
@@ -503,7 +566,10 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<div className="flex justify-start">
<button
type="button"
onClick={() => setIsMapOpen(true)}
onClick={(e) => {
e.stopPropagation();
setIsMapOpen(true);
}}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all"
>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
@@ -514,14 +580,20 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setIsEditing(false)}
onClick={(e) => {
e.stopPropagation();
setIsEditing(false);
}}
disabled={isSavingEdit}
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-bold transition-all"
>
Hủy
</button>
<button
onClick={handleSaveEdit}
onClick={(e) => {
e.stopPropagation();
handleSaveEdit();
}}
disabled={isSavingEdit}
className="flex items-center gap-1 px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
>
@@ -558,7 +630,10 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
</div>
{isAuthorized && (
<button
onClick={() => setIsEditing(true)}
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="p-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700/50 rounded-xl text-slate-300 hover:text-white transition-all shrink-0"
title="Chỉnh sửa thông tin"
>
@@ -625,7 +700,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<div>
<h3 className="text-lg font-black tracking-tight text-white flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-emerald-500" />
Bình luận cộng đng
{t('commentSectionTitle')}
</h3>
<p className="text-xs text-slate-400 mt-1">nh chia sẻ công khai trên bản đ</p>
</div>
@@ -636,7 +711,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
{isLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2">
<Loader2 className="w-8 h-8 animate-spin text-emerald-500" />
<span className="text-xs font-semibold">Đang tải bình luận...</span>
<span className="text-xs font-semibold">{t('loading')}</span>
</div>
) : comments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500 gap-3">
@@ -656,9 +731,26 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
<span className="text-[9px] font-medium text-slate-500">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
<div className="flex items-center gap-2">
<span className="text-[9px] font-medium text-slate-500">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{(currentUser?.isAdmin ||
currentUser?.id === c.userId ||
currentUser?.id === photo.uploaderId ||
currentUser?.id === photo.uploader?.id) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteComment(c.id);
}}
className="text-slate-500 hover:text-rose-500 transition-colors p-0.5"
title={t('delete') || "Xóa"}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
</div>
@@ -667,6 +759,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
);
})
)}
<div ref={commentsEndRef} />
</div>
@@ -683,7 +776,10 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
disabled={isSending}
/>
<button
onClick={handleSend}
onClick={(e) => {
e.stopPropagation();
handleSend();
}}
disabled={!newComment.trim() || isSending}
className="p-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-40 text-white rounded-2xl transition-all active:scale-95 shadow-lg shadow-emerald-950/30 flex items-center justify-center"
>
@@ -725,7 +821,11 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<img
src={photo.imageUrl}
alt="Fullscreen photo"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200 ${
!isLoggedIn ? 'pointer-events-none' : ''
}`}
/>
</div>
)}
@@ -0,0 +1,288 @@
import React, { useState } from 'react';
import { X, ShieldAlert, MapPin, Loader2, Phone, Mail, AlertTriangle } from 'lucide-react';
import { useTranslation } from '../hooks/useTranslation';
interface ReportBusinessModalProps {
isOpen: boolean;
onClose: () => void;
initialLatitude?: number;
initialLongitude?: number;
}
export const ReportBusinessModal: React.FC<ReportBusinessModalProps> = ({
isOpen,
onClose,
initialLatitude,
initialLongitude
}) => {
const { t } = useTranslation();
const [type, setType] = useState('RESTAURANT');
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [latitude, setLatitude] = useState(initialLatitude ? String(initialLatitude) : '');
const [longitude, setLongitude] = useState(initialLongitude ? String(initialLongitude) : '');
const [reason, setReason] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
React.useEffect(() => {
if (isOpen) {
setLatitude(initialLatitude ? String(initialLatitude) : '');
setLongitude(initialLongitude ? String(initialLongitude) : '');
setSuccess(false);
setError('');
}
}, [isOpen, initialLatitude, initialLongitude]);
if (!isOpen) return null;
const handleGetCurrentLocation = () => {
if (!navigator.geolocation) {
setError('Trình duyệt không hỗ trợ định vị GPS.');
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(String(position.coords.latitude.toFixed(6)));
setLongitude(String(position.coords.longitude.toFixed(6)));
},
() => {
setError('Không thể lấy vị trí hiện tại. Vui lòng bật định vị GPS.');
}
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const response = await fetch(`/api/v1/reports`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
name,
phone: phone || null,
email: email || null,
address: address || null,
latitude: latitude ? parseFloat(latitude) : null,
longitude: longitude ? parseFloat(longitude) : null,
reason,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Gửi báo cáo thất bại.');
}
setSuccess(true);
setTimeout(() => {
onClose();
// Reset form
setName('');
setPhone('');
setEmail('');
setAddress('');
setLatitude('');
setLongitude('');
setReason('');
}, 2000);
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300"
onClick={onClose}
/>
{/* Content Container */}
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300 flex flex-col max-h-[90vh]">
{/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-50 text-red-500 rounded-xl">
<ShieldAlert className="w-6 h-6" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900">{t('reportModalTitle')}</h2>
<p className="text-xs text-gray-500 mt-0.5">Báo cáo các hành vi không lành mạnh hoặc lừa đo kinh doanh.</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
{success ? (
<div className="p-10 flex flex-col items-center justify-center text-center space-y-4">
<div className="w-16 h-16 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center shadow-lg animate-bounce">
<ShieldAlert className="w-8 h-8" />
</div>
<h3 className="text-xl font-bold text-gray-900">{t('success')}!</h3>
<p className="text-sm text-gray-500 max-w-sm">{t('reportSuccess')}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1 text-left">
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded-2xl text-xs font-bold flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Loại hình */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">{t('businessType')} *</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm font-bold text-gray-800"
>
<option value="USER">{t('typeUser')}</option>
<option value="RESTAURANT">{t('typeRestaurant')}</option>
<option value="HOTEL">{t('typeHotel')}</option>
<option value="HOMESTAY">{t('typeHomestay')}</option>
</select>
</div>
{/* Tên */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">{t('businessName')} *</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="VD: Nhà hàng ABC, Homestay X..."
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm text-gray-800"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Số điện thoại */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-1">
<Phone className="w-3.5 h-3.5 text-gray-400" /> {t('businessPhone')}
</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="0987xxxxxx"
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm text-gray-800"
/>
</div>
{/* Email */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-1">
<Mail className="w-3.5 h-3.5 text-gray-400" /> {t('businessEmail')}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="contact@business.com"
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm text-gray-800"
/>
</div>
</div>
{/* Địa chỉ */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-gray-400" /> {t('businessAddress')}
</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="VD: 123 Đường Trần Phú, Đà Lạt..."
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all text-sm text-gray-800"
/>
</div>
{/* Tọa độ địa lý */}
<div className="space-y-1.5 bg-blue-50/50 p-4 rounded-2xl border border-blue-100">
<div className="flex justify-between items-center mb-2">
<span className="text-xs font-bold text-blue-700 uppercase tracking-wider flex items-center gap-1.5">
📍 Vị trí đa (Tùy chọn)
</span>
<button
type="button"
onClick={handleGetCurrentLocation}
className="text-xs font-bold text-blue-600 hover:text-blue-700 hover:underline flex items-center gap-1"
>
Lấy vị trí GPS hiện tại
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-bold text-gray-400 uppercase mb-1"> đ (Latitude)</label>
<input
type="number"
step="any"
value={latitude}
onChange={(e) => setLatitude(e.target.value)}
placeholder="11.9404"
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-xs text-gray-800"
/>
</div>
<div>
<label className="block text-[10px] font-bold text-gray-400 uppercase mb-1">Kinh đ (Longitude)</label>
<input
type="number"
step="any"
value={longitude}
onChange={(e) => setLongitude(e.target.value)}
placeholder="108.4382"
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-xs text-gray-800"
/>
</div>
</div>
</div>
{/* Lý do */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">{t('reportReason')} *</label>
<textarea
required
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Hãy mô tả hành vi không đàng hoàng, lừa đảo hoặc gian dối của cơ sở/người dùng này..."
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all resize-none text-sm text-gray-800"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-4 bg-red-600 hover:bg-red-700 text-white font-bold rounded-2xl shadow-lg shadow-red-100 transition-all flex items-center justify-center gap-2 active:scale-[0.98] disabled:opacity-50"
>
{isLoading ? 'Đang gửi...' : t('submitReport')}
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : <ShieldAlert className="w-5 h-5" />}
</button>
</form>
)}
</div>
</div>
);
};
+655
View File
@@ -0,0 +1,655 @@
import React, { useState, useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import { Send, MessageSquare, Loader2, Image as ImageIcon, MapPin, Download, X } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
interface TourChatProps {
tourId: string;
}
export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
const notify = useNotification();
const [messages, setMessages] = useState<any[]>([]);
const [newMessage, setNewMessage] = useState('');
const [loading, setLoading] = useState(true);
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [attachedLocation, setAttachedLocation] = useState<{ latitude: number; longitude: number } | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isLocating, setIsLocating] = useState(false);
const [participants, setParticipants] = useState<any[]>([]);
const [showMentionList, setShowMentionList] = useState(false);
const [mentionSearch, setMentionSearch] = useState('');
const [mentionIndex, setMentionIndex] = useState(0);
const [taggedUserIds, setTaggedUserIds] = useState<string[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const socketRef = useRef<Socket | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const mentionRef = useRef<HTMLDivElement>(null);
const getHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
});
const currentUserId = (() => {
try {
const token = localStorage.getItem('token');
if (!token) return null;
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
const parsed = JSON.parse(jsonPayload);
return parsed.sub || parsed.id;
} catch (e) {
return null;
}
})();
// Fetch tour details to get participants (excluding current user)
useEffect(() => {
const fetchTourDetails = async () => {
try {
const res = await fetch(`/api/v1/tours/${tourId}`, { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
if (data && data.participants) {
const memberList = data.participants
.map((p: any) => p.user)
.filter((u: any) => u && u.id !== currentUserId);
setParticipants(memberList);
}
}
} catch (err) {
console.error('Lỗi khi tải thông tin thành viên tour:', err);
}
};
if (tourId && currentUserId) {
fetchTourDetails();
}
}, [tourId, currentUserId]);
// Click outside to close mention dropdown
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (mentionRef.current && !mentionRef.current.contains(e.target as Node)) {
setShowMentionList(false);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, []);
const filteredParticipants = participants.filter(p =>
p.name.toLowerCase().includes(mentionSearch.toLowerCase())
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setNewMessage(value);
const selectionStart = e.target.selectionStart || 0;
const textBeforeCursor = value.slice(0, selectionStart);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
if (!textAfterAt.includes(' ')) {
setShowMentionList(true);
setMentionSearch(textAfterAt);
setMentionIndex(0);
return;
}
}
setShowMentionList(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!showMentionList) return;
const filtered = filteredParticipants;
if (filtered.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionIndex(prev => (prev + 1) % filtered.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setMentionIndex(prev => (prev - 1 + filtered.length) % filtered.length);
} else if (e.key === 'Enter') {
e.preventDefault();
insertMention(filtered[mentionIndex]);
} else if (e.key === 'Escape') {
e.preventDefault();
setShowMentionList(false);
}
};
const insertMention = (member: { id: string; name: string }) => {
const input = inputRef.current;
if (!input) return;
const selectionStart = input.selectionStart || 0;
const textBeforeCursor = newMessage.slice(0, selectionStart);
const textAfterCursor = newMessage.slice(selectionStart);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
const newTextBeforeCursor = textBeforeCursor.slice(0, lastAtIndex) + `@${member.name} `;
const updatedValue = newTextBeforeCursor + textAfterCursor;
setNewMessage(updatedValue);
setShowMentionList(false);
if (!taggedUserIds.includes(member.id)) {
setTaggedUserIds(prev => [...prev, member.id]);
}
setTimeout(() => {
input.focus();
const cursorPosition = newTextBeforeCursor.length;
input.setSelectionRange(cursorPosition, cursorPosition);
}, 0);
}
};
// Fetch past messages
useEffect(() => {
const fetchMessages = async () => {
try {
const res = await fetch(`/api/v1/tours/${tourId}/messages`, { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
setMessages(data || []);
}
} catch (err) {
console.error('Lỗi khi tải tin nhắn:', err);
} finally {
setLoading(false);
}
};
fetchMessages();
}, [tourId]);
// Connect to socket and listen for tour messages
useEffect(() => {
const socket = io();
socketRef.current = socket;
socket.on('connect', () => {
socket.emit('joinTour', tourId);
});
socket.on('tourMessageReceived', (data: any) => {
if (data.tourId === tourId) {
setMessages(prev => [...prev, data.message]);
}
});
return () => {
socket.disconnect();
};
}, [tourId]);
// Autoscroll chat to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Compress image to 2K (max 2048px longest side)
const compressImageTo2K = (file: File): Promise<Blob> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const MAX_DIM = 2048;
let width = img.width;
let height = img.height;
if (width > MAX_DIM || height > MAX_DIM) {
if (width > height) {
height = Math.round((height * MAX_DIM) / width);
width = MAX_DIM;
} else {
width = Math.round((width * MAX_DIM) / height);
height = MAX_DIM;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(file);
return;
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
resolve(file);
}
},
'image/jpeg',
0.85
);
};
img.onerror = (err) => reject(err);
};
reader.onerror = (err) => reject(err);
});
};
// Handle Image Selection
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedImage(file);
setImagePreview(URL.createObjectURL(file));
}
};
// Handle Location Sharing
const handleGetLocation = () => {
if (!navigator.geolocation) {
notify({
title: 'Không hỗ trợ',
message: 'Trình duyệt của bạn không hỗ trợ định vị GPS.',
type: 'error'
});
return;
}
setIsLocating(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setAttachedLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude
});
notify({
title: 'Gắn vị trí thành công',
message: 'Vị trí hiện tại đã được đính kèm vào tin nhắn.',
type: 'success'
});
setIsLocating(false);
},
(error) => {
console.error('Lỗi định vị:', error);
notify({
title: 'Lỗi GPS',
message: 'Không thể lấy vị trí hiện tại của bạn. Hãy kiểm tra quyền truy cập.',
type: 'error'
});
setIsLocating(false);
},
{ enableHighAccuracy: true, timeout: 10000 }
);
};
// Upload image to backend
const uploadImage = async (file: File): Promise<string | null> => {
try {
setIsUploading(true);
// Compress first
const compressedBlob = await compressImageTo2K(file);
const formData = new FormData();
formData.append('image', compressedBlob, 'compressed.jpg');
const res = await fetch('/api/v1/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
});
if (res.ok) {
const data = await res.json();
return data.url;
}
return null;
} catch (err) {
console.error('Lỗi upload ảnh:', err);
return null;
} finally {
setIsUploading(false);
}
};
// Send Tour Message
const handleSendMessage = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!newMessage.trim() && !selectedImage && !attachedLocation) return;
let attachmentUrl = undefined;
if (selectedImage) {
attachmentUrl = await uploadImage(selectedImage);
if (!attachmentUrl) {
notify({
title: 'Lỗi',
message: 'Không thể tải ảnh đính kèm lên server.',
type: 'error'
});
return;
}
}
const actualTaggedUserIds = taggedUserIds.filter(userId => {
const member = participants.find(p => p.id === userId);
if (!member || !member.name) return false;
const cleanMessage = newMessage.toLowerCase();
const nameLower = member.name.toLowerCase();
// Try exact match first
if (cleanMessage.includes(`@${nameLower}`)) return true;
// Try match without parentheses (e.g. "Lộc Phạm (Chủ Tour)" -> "Lộc Phạm")
const nameWithoutParentheses = member.name.split('(')[0].trim().toLowerCase();
if (nameWithoutParentheses && cleanMessage.includes(`@${nameWithoutParentheses}`)) return true;
return false;
});
const payload = {
content: newMessage,
attachmentUrl,
latitude: attachedLocation?.latitude,
longitude: attachedLocation?.longitude,
taggedUserIds: actualTaggedUserIds
};
// Reset input fields immediately
setNewMessage('');
setSelectedImage(null);
setImagePreview(null);
setAttachedLocation(null);
setTaggedUserIds([]);
try {
const res = await fetch(`/api/v1/tours/${tourId}/messages`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload)
});
if (!res.ok) {
notify({
title: 'Lỗi',
message: 'Gửi tin nhắn thất bại.',
type: 'error'
});
}
} catch (err) {
console.error('Lỗi gửi tin nhắn:', err);
}
};
// Download image file helper
const handleDownloadImage = async (url: string, id: string) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `tour-chat-photo-${id}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (error) {
console.error('Lỗi tải ảnh:', error);
window.open(url, '_blank');
}
};
// currentUserId is defined at the top
return (
<div className="bg-white border border-gray-150 rounded-2xl shadow-lg overflow-hidden flex flex-col h-[500px]">
{/* Chat Header */}
<div className="p-4 border-b border-gray-150 flex items-center gap-2 bg-gray-50/50">
<MessageSquare className="w-5 h-5 text-blue-500" />
<div>
<h3 className="text-sm font-bold text-gray-800">Trò chuyện nhóm hành trình</h3>
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đi thông tin, hình nh đnh vị giữa các thành viên</p>
</div>
</div>
{/* Messages list */}
<div className="flex-1 p-4 overflow-y-auto flex flex-col gap-3 min-h-0 bg-slate-50/20">
{loading ? (
<div className="flex-1 flex items-center justify-center text-gray-400 text-xs gap-1.5">
<Loader2 className="w-4 h-4 animate-spin text-blue-500" /> Đang tải tin nhắn...
</div>
) : messages.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-450 text-xs italic gap-1.5">
<MessageSquare className="w-8 h-8 text-gray-300" />
<span className="text-gray-400">Chưa tin nhắn nào trong phòng chat nhóm này.</span>
</div>
) : (
messages.map((msg) => {
const isMe = msg.senderId === currentUserId;
const initials = msg.sender?.name
? msg.sender.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()
: 'U';
return (
<div
key={msg.id}
className={`flex gap-2 max-w-[80%] ${isMe ? 'self-end flex-row-reverse' : 'self-start'}`}
>
{!isMe && (
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 to-indigo-500 flex items-center justify-center font-bold text-[10px] text-white shadow-sm shrink-0">
{msg.sender?.avatar ? (
<img src={msg.sender.avatar} alt={msg.sender.name} className="w-full h-full rounded-full object-cover" />
) : initials}
</div>
)}
<div className={`flex flex-col ${isMe ? 'items-end' : 'items-start'}`}>
{!isMe && (
<span className="text-[10px] font-bold text-gray-500 mb-0.5 ml-1">
{msg.sender?.name || 'Thành viên'}
</span>
)}
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${
isMe
? 'bg-blue-600 text-white rounded-tr-none'
: 'bg-white text-gray-700 rounded-tl-none border border-gray-150 shadow-sm'
}`}>
{/* Attachment Image */}
{msg.attachmentUrl && (
<div className="relative rounded-lg overflow-hidden border border-black/5 max-w-xs group/img">
<img
src={msg.attachmentUrl}
alt="Đính kèm"
className="w-full max-h-48 object-cover hover:brightness-95 transition-all"
/>
<button
type="button"
onClick={() => handleDownloadImage(msg.attachmentUrl, msg.id)}
className="absolute bottom-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md transition-all shadow-md flex items-center justify-center"
title="Tải ảnh này về máy"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
)}
{/* GPS Location badge */}
{msg.latitude !== undefined && msg.latitude !== null && (
<a
href={`https://www.google.com/maps/search/?api=1&query=${msg.latitude},${msg.longitude}`}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${
isMe
? 'bg-blue-700 border-blue-600 text-blue-100 hover:bg-blue-800'
: 'bg-gray-100 border-gray-200 text-gray-750 hover:bg-gray-200'
}`}
>
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
<div className="flex flex-col text-left">
<span>Vị trí hiện tại</span>
<span className="text-[9px] opacity-75">{msg.latitude.toFixed(6)}, {msg.longitude.toFixed(6)}</span>
</div>
</a>
)}
{/* Content text */}
{msg.content && <p className="whitespace-pre-wrap break-words">{msg.content}</p>}
</div>
<span className="text-[8px] text-gray-400 font-bold mt-1 px-1">
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Previews (Image & GPS Location) */}
{(imagePreview || attachedLocation) && (
<div className="px-4 py-2 border-t border-gray-150 bg-gray-50/80 flex flex-wrap gap-2">
{imagePreview && (
<div className="relative w-16 h-16 rounded-lg overflow-hidden border border-gray-200 shadow-sm">
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => {
setSelectedImage(null);
setImagePreview(null);
}}
className="absolute top-0.5 right-0.5 p-0.5 bg-black/60 hover:bg-black text-white rounded-full transition-all"
>
<X className="w-3 h-3" />
</button>
</div>
)}
{attachedLocation && (
<div className="flex items-center gap-1.5 bg-rose-50 border border-rose-200 rounded-lg px-2.5 py-1 text-xs text-rose-700 font-bold">
<MapPin className="w-3.5 h-3.5 text-rose-500 animate-pulse" />
<span>Đã đính kèm GPS</span>
<button
type="button"
onClick={() => setAttachedLocation(null)}
className="hover:text-rose-950 transition-colors ml-1"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
)}
{/* Chat Input wrapper */}
<div className="relative">
{/* Mention list dropdown */}
{showMentionList && filteredParticipants.length > 0 && (
<div
ref={mentionRef}
className="absolute bottom-full left-3 right-3 mb-2 bg-white border border-gray-200 rounded-xl shadow-xl max-h-40 overflow-y-auto z-50 flex flex-col py-1"
>
{filteredParticipants.map((member, index) => (
<button
key={member.id}
type="button"
onClick={() => insertMention(member)}
className={`px-3 py-2 text-left text-xs font-semibold flex items-center gap-2 transition-colors ${
index === mentionIndex
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
<div className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center font-bold text-[9px] text-blue-600">
{member.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
</div>
<span>{member.name}</span>
<span className="text-[10px] text-gray-400 font-medium font-mono">@{member.name}</span>
</button>
))}
</div>
)}
{/* Chat Input form */}
<form
onSubmit={handleSendMessage}
className="p-3 border-t border-gray-150 bg-gray-50 flex gap-2 items-center"
>
<input
type="file"
accept="image/*"
ref={fileInputRef}
onChange={handleImageChange}
className="hidden"
/>
{/* Attach photo button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="p-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-500 rounded-xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0"
title="Đính kèm hình ảnh"
>
<ImageIcon className="w-4 h-4 text-blue-500" />
</button>
{/* Share current GPS button */}
<button
type="button"
onClick={handleGetLocation}
disabled={isLocating}
className={`p-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-500 rounded-xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0 ${
isLocating ? 'animate-pulse' : ''
}`}
title="Chia sẻ vị trí GPS hiện tại"
>
{isLocating ? (
<Loader2 className="w-4 h-4 animate-spin text-rose-500" />
) : (
<MapPin className="w-4 h-4 text-rose-500" />
)}
</button>
<input
type="text"
ref={inputRef}
value={newMessage}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={isUploading ? "Đang tải ảnh lên..." : "Nhập nội dung tin nhắn..."}
disabled={isUploading}
className="flex-1 bg-white border border-gray-200 rounded-xl py-2.5 px-3 text-xs text-gray-800 placeholder-gray-400 outline-none focus:border-blue-500 transition-all shadow-inner disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
<button
type="submit"
disabled={isUploading || (!newMessage.trim() && !selectedImage && !attachedLocation)}
className="p-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl transition-all shadow-md active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</button>
</form>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
const loadScript = (src: string, fallbackSrcs?: string[]): Promise<void> => {
return new Promise((resolve, reject) => {
const allSrcs = [src, ...(fallbackSrcs || [])];
// Check if any of the scripts are already loaded
if (allSrcs.some(s => document.querySelector(`script[src="${s}"]`))) {
resolve();
return;
}
const tryLoadScript = (index: number) => {
if (index >= allSrcs.length) {
reject(new Error(`Failed to load script from any source: ${allSrcs.join(', ')}`));
return;
}
const currentSrc = allSrcs[index];
const script = document.createElement('script');
script.src = currentSrc;
script.onload = () => resolve();
script.onerror = () => {
console.warn(`Failed to load script ${currentSrc}. Trying next fallback...`);
const nextIndex = index + 1;
if (nextIndex < allSrcs.length) {
tryLoadScript(nextIndex);
} else {
reject(new Error(`Failed to load script from all sources: ${allSrcs.join(', ')}`));
}
};
document.head.appendChild(script);
};
tryLoadScript(0);
});
};
const loadModerationLibraries = async () => {
// Load TensorFlow first with fallbacks
await loadScript(
'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs',
['https://unpkg.com/@tensorflow/tfjs', 'https://esm.sh/@tensorflow/tfjs']
);
// Load models after tfjs is available, with multiple fallbacks
await Promise.all([
loadScript(
'https://cdn.jsdelivr.net/npm/@tensorflow-models/blazeface',
['https://unpkg.com/@tensorflow-models/blazeface', 'https://esm.sh/@tensorflow-models/blazeface']
),
// NSFWJS with 3 CDN fallbacks
loadScript(
'https://cdn.jsdelivr.net/npm/nsfwjs@2.4.0/dist/bundle.js',
[
'https://unpkg.com/nsfwjs@2.4.0/dist/bundle.js',
'https://esm.sh/nsfwjs@2.4.0/dist/bundle.js'
]
)
]);
};
export const processImageModeration = async (file: File): Promise<{ file: File; blocked: boolean }> => {
try {
const settingsRes = await fetch('/api/v1/moderation/settings');
if (!settingsRes.ok) return { file, blocked: false };
const settings = await settingsRes.json();
const { blockNsfw, blurFaces } = settings;
if (!blockNsfw && !blurFaces) {
return { file, blocked: false };
}
// Try to load moderation libraries, but don't fail if they're unavailable
try {
await loadModerationLibraries();
} catch (libLoadErr) {
console.warn('Moderation libraries failed to load, proceeding without NSFW/Face blur checks:', libLoadErr);
return { file, blocked: false };
}
return new Promise((resolve) => {
const img = new Image();
img.onload = async () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve({ file, blocked: false });
return;
}
ctx.drawImage(img, 0, 0);
if (blockNsfw) {
try {
const nsfwModel = await (window as any).nsfwjs?.load();
if (nsfwModel) {
const predictions = await nsfwModel.classify(canvas);
const pornOrHentai = predictions.find((p: any) => p.className === 'Porn' || p.className === 'Hentai');
const pornProb = pornOrHentai ? pornOrHentai.probability : 0;
if (pornProb > 0.5) {
console.warn(`Image blocked by NSFW filter (probability: ${pornProb})`);
resolve({ file, blocked: true });
return;
}
}
} catch (e) {
console.warn('NSFW validation error (will allow upload):', e);
}
}
let modified = false;
if (blurFaces) {
try {
const blazefaceModel = await (window as any).blazeface?.load();
if (blazefaceModel) {
const predictions = await blazefaceModel.estimateFaces(canvas, false);
if (predictions && predictions.length > 0) {
modified = true;
predictions.forEach((prediction: any) => {
const startX = prediction.topLeft[0];
const startY = prediction.topLeft[1];
const endX = prediction.bottomRight[0];
const endY = prediction.bottomRight[1];
const width = endX - startX;
const height = endY - startY;
const faceCanvas = document.createElement('canvas');
faceCanvas.width = width;
faceCanvas.height = height;
const faceCtx = faceCanvas.getContext('2d');
if (faceCtx) {
faceCtx.drawImage(canvas, startX, startY, width, height, 0, 0, width, height);
ctx.filter = 'blur(15px)';
ctx.drawImage(faceCanvas, startX, startY, width, height);
ctx.filter = 'none';
}
});
}
}
} catch (e) {
console.warn('Face blur error (will skip face detection):', e);
}
}
if (modified) {
canvas.toBlob((blob) => {
if (blob) {
const processedFile = new File([blob], file.name, { type: file.type });
resolve({ file: processedFile, blocked: false });
} else {
resolve({ file, blocked: false });
}
}, file.type);
} else {
resolve({ file, blocked: false });
}
};
img.onerror = () => {
resolve({ file, blocked: false });
};
img.src = URL.createObjectURL(file);
});
} catch (err) {
console.error('Image moderation process failed:', err);
return { file, blocked: false };
}
};
+68
View File
@@ -0,0 +1,68 @@
import { useState, useEffect } from 'react';
export type Theme = 'light' | 'dark' | 'system';
export const useTheme = () => {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('theme') as Theme) || 'system';
});
const applyTheme = (currentTheme: Theme) => {
const root = document.documentElement;
root.classList.remove('light', 'dark');
if (currentTheme === 'dark') {
root.classList.add('dark');
root.style.colorScheme = 'dark';
} else if (currentTheme === 'light') {
root.classList.add('light');
root.style.colorScheme = 'light';
} else {
// System
const systemIsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (systemIsDark) {
root.classList.add('dark');
root.style.colorScheme = 'dark';
} else {
root.classList.add('light');
root.style.colorScheme = 'light';
}
}
};
const changeTheme = (newTheme: Theme) => {
localStorage.setItem('theme', newTheme);
setTheme(newTheme);
applyTheme(newTheme);
window.dispatchEvent(new Event('themeChange'));
};
useEffect(() => {
applyTheme(theme);
// Listen for system theme changes if theme is set to 'system'
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
if (localStorage.getItem('theme') === 'system' || !localStorage.getItem('theme')) {
applyTheme('system');
}
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
const handleStorageChange = () => {
const storedTheme = (localStorage.getItem('theme') as Theme) || 'system';
setTheme(storedTheme);
applyTheme(storedTheme);
};
window.addEventListener('themeChange', handleStorageChange);
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange);
window.removeEventListener('themeChange', handleStorageChange);
};
}, [theme]);
return { theme, changeTheme };
};
+324
View File
@@ -0,0 +1,324 @@
import { useState, useEffect } from 'react';
export type Language = 'vi' | 'en' | 'zh';
const translations: Record<string, Record<Language, string>> = {
// Common
appName: { vi: 'Travel Planner', en: 'Travel Planner', zh: '旅行规划' },
login: { vi: 'Đăng nhập', en: 'Log In', zh: '登录' },
signup: { vi: 'Đăng ký', en: 'Sign Up', zh: '注册' },
logout: { vi: 'Đăng xuất', en: 'Log Out', zh: '登出' },
cancel: { vi: 'Hủy', en: 'Cancel', zh: '取消' },
confirm: { vi: 'Xác nhận', en: 'Confirm', zh: '确认' },
save: { vi: 'Lưu', en: 'Save', zh: '保存' },
saving: { vi: 'Đang lưu...', en: 'Saving...', zh: '保存中...' },
loading: { vi: 'Đang tải...', en: 'Loading...', zh: '加载中...' },
success: { vi: 'Thành công', en: 'Success', zh: '成功' },
error: { vi: 'Lỗi', en: 'Error', zh: '错误' },
info: { vi: 'Thông tin', en: 'Info', zh: '信息' },
delete: { vi: 'Xóa', en: 'Delete', zh: '删除' },
edit: { vi: 'Chỉnh sửa', en: 'Edit', zh: '编辑' },
yes: { vi: 'Có', en: 'Yes', zh: '是' },
no: { vi: 'Không', en: 'No', zh: '否' },
close: { vi: 'Đóng', en: 'Close', zh: '关闭' },
ok: { vi: 'Đồng ý', en: 'OK', zh: '确定' },
// Landing Page
welcomeBack: { vi: 'Chào mừng bạn quay trở lại!', en: 'Welcome back!', zh: '欢迎回来!' },
emailLabel: { vi: 'Email', en: 'Email', zh: '邮箱' },
passwordLabel: { vi: 'Mật khẩu', en: 'Password', zh: '密码' },
forgotPassword: { vi: 'Quên mật khẩu?', en: 'Forgot password?', zh: '忘记密码?' },
orLabel: { vi: 'Hoặc', en: 'Or', zh: '或' },
noAccount: { vi: 'Chưa có tài khoản?', en: "Don't have an account?", zh: '还没有账号?' },
createAccountNow: { vi: 'Tạo tài khoản ngay', en: 'Create one now', zh: '立即注册' },
quickCamera: { vi: 'Chụp ảnh nhanh', en: 'Quick Camera', zh: '快速相机' },
momentsTitle: { vi: 'Khoảnh khắc cộng đồng', en: 'Community Moments', zh: '社区精彩瞬间' },
trustedMembers: { vi: 'Thành viên uy tín', en: 'Trusted Members', zh: '信用会员' },
exploreToursBtn: { vi: 'Khám phá các hành trình du lịch', en: 'Explore Travel Itineraries', zh: '探索旅行行程' },
exploreTourMap: { vi: 'Khám phá Bản đồ Tour', en: 'Explore Tour Map', zh: '探索旅游地图' },
// Explore Map
systemBtn: { vi: 'Hệ thống', en: 'System', zh: '系统管理' },
createTourBtn: { vi: 'Tạo Tour', en: 'Create Tour', zh: '创建行程' },
chooseLocationMap: { vi: 'Chọn vị trí trên bản đồ', en: 'Choose location on map', zh: '在地图上选择位置' },
clickMapSelectCoords: { vi: 'Click lên bản đồ để chọn tọa độ', en: 'Click on map to select coordinates', zh: '在地图上点击以选择坐标' },
coordsLabel: { vi: 'Tọa độ', en: 'Coordinates', zh: '坐标' },
businessRestaurant: { vi: 'Nhà hàng', en: 'Restaurant', zh: '餐厅' },
businessHotel: { vi: 'Khách sạn', en: 'Hotel', zh: '酒店' },
businessHomestay: { vi: 'Homestay', en: 'Homestay', zh: 'Homestay' },
noCoordsSelected: { vi: 'Chưa chọn vị trí', en: 'No location selected', zh: '未选择位置' },
searchPlaceholder: { vi: 'Tìm kiếm địa điểm...', en: 'Search places...', zh: '搜索地点...' },
// Itinerary Timeline
legLabel: { vi: 'Chặng', en: 'Leg', zh: '航段' },
legSequence: { vi: 'Chi tiết Chặng', en: 'Leg Details', zh: '航段详情' },
startDate: { vi: 'Bắt đầu', en: 'Start Date', zh: '开始日期' },
endDate: { vi: 'Kết thúc', en: 'End Date', zh: '结束日期' },
addLocation: { vi: 'Thêm địa điểm', en: 'Add Location', zh: '添加地点' },
dwellTime: { vi: 'Thời gian dừng', en: 'Dwell Time', zh: '停留时间' },
expenseLabel: { vi: 'Chi phí', en: 'Expense', zh: '费用' },
paidByLabel: { vi: 'Người chi trả', en: 'Paid By', zh: '付款人' },
optimizeBtn: { vi: 'Tối ưu', en: 'Optimize', zh: '优化' },
startPoint: { vi: 'Điểm bắt đầu', en: 'Start Point', zh: '起点' },
endPoint: { vi: 'Điểm kết thúc', en: 'End Point', zh: '终点' },
pinStartHelper: { vi: 'Nhấn để ghim điểm bắt đầu cho Tour...', en: 'Click to pin starting point...', zh: '点击锁定行程起点...' },
pinEndHelper: { vi: 'Nhấn để ghim điểm kết thúc cho Tour...', en: 'Click to pin ending point...', zh: '点击锁定行程终点...' },
noLegs: { vi: 'Chưa có chặng nào trong lộ trình.', en: 'No legs in the itinerary yet.', zh: '行程中暂无航段。' },
declareLegsBtn: { vi: 'Khai báo số chặng', en: 'Declare Leg Count', zh: '申报航段数' },
addSingleLeg: { vi: 'Thêm chặng lẻ vào cuối', en: 'Add leg to end', zh: '末尾添加单个航段' },
exportPDF: { vi: 'Xuất PDF', en: 'Export PDF', zh: '导出 PDF' },
// Tour Detail / Organizer Rating
rateOrganizer: { vi: 'Đánh giá ban tổ chức', en: 'Rate Organizer', zh: '评估组织者' },
honesty: { vi: 'Trung thực', en: 'Honesty', zh: '诚实度' },
transparency: { vi: 'Minh bạch', en: 'Transparency', zh: '透明度' },
enthusiasm: { vi: 'Nhiệt tình', en: 'Enthusiasm', zh: '热情度' },
cheerfulness: { vi: 'Vui vẻ', en: 'Cheerfulness', zh: '愉快度' },
seriousness: { vi: 'Nhiêm túc', en: 'Seriousness', zh: '认真度' },
planning: { vi: 'Có kế hoạch', en: 'Planning Skills', zh: '计划性' },
survival: { vi: 'Kỹ năng sinh tồn', en: 'Survival Skills', zh: '生存技能' },
rateTitle: { vi: 'Đánh giá Người tạo Tour', en: 'Rate Tour Creator', zh: '评价行程发起人' },
rateCommentPlaceholder: { vi: 'Nhập ý kiến đánh giá khác...', en: 'Enter other comments...', zh: '输入其他评价...' },
emergencyShare: { vi: 'Chia sẻ khẩn cấp', en: 'Emergency Share', zh: '紧急分享' },
emergencyShareTooltip: { vi: 'Bật chia sẻ để người thân có thể định vị bạn khi khẩn cấp', en: 'Enable sharing so family can locate you in emergencies', zh: '开启分享以便家人在紧急情况下定位您' },
copyShareLink: { vi: 'Sao chép liên kết chia sẻ', en: 'Copy share link', zh: '复制分享链接' },
// User Management / Moderation
tabUsers: { vi: 'Người dùng', en: 'Users', zh: '用户管理' },
tabPhotos: { vi: 'Ảnh công khai', en: 'Public Photos', zh: '公开照片' },
tabTrash: { vi: 'Ảnh rác', en: 'Trash Photos', zh: '垃圾照片' },
tabFilters: { vi: 'Bộ lọc', en: 'Filters', zh: '过滤器' },
filterNsfwToggle: { vi: 'Lọc hình ảnh khiêu dâm', en: 'Block NSFW Images', zh: '过滤淫秽图片' },
filterFaceBlurToggle: { vi: 'Làm mờ khuôn mặt', en: 'Automatic Face Blur', zh: '自动模糊人脸' },
wordFiltersTitle: { vi: 'Từ khóa cấm & Thay thế', en: 'Banned Words & Replacements', zh: '禁用词及替换词' },
addWordBtn: { vi: 'Thêm từ khóa', en: 'Add Word', zh: '添加词汇' },
wordLabel: { vi: 'Từ cấm', en: 'Banned Word', zh: '敏感词' },
replacementLabel: { vi: 'Từ thay thế', en: 'Replacement', zh: '替换词' },
commentSectionTitle: { vi: 'Bình luận cộng đồng', en: 'Community Comments', zh: '社区评论' },
// Dashboard / General Settings
myItineraries: { vi: 'Hành trình của tôi', en: 'My Itineraries', zh: '我的行程' },
chatMenu: { vi: 'Trò chuyện', en: 'Chat', zh: '聊天' },
friendsMenu: { vi: 'Danh sách bạn bè', en: 'Friends List', zh: '好友列表' },
muteNotifications: { vi: 'Tắt thông báo đẩy', en: 'Mute Notifications', zh: '关闭推送通知' },
unmuteNotifications: { vi: 'Bật thông báo đẩy', en: 'Unmute Notifications', zh: '开启推送通知' },
enterSecretKey: { vi: 'Nhập Admin Secret Key để mở khóa', en: 'Enter Admin Secret Key to unlock', zh: '输入管理员密钥解锁' },
invalidSecretKey: { vi: 'Mã Secret Key không hợp lệ', en: 'Invalid Secret Key', zh: '密钥无效' },
languageSelect: { vi: 'Ngôn ngữ', en: 'Language', zh: '语言' },
themeSelect: { vi: 'Giao diện', en: 'Theme', zh: '主题' },
themeLight: { vi: 'Sáng', en: 'Light', zh: '浅色' },
themeDark: { vi: 'Tối', en: 'Dark', zh: '深色' },
themeSystem: { vi: 'Hệ thống', en: 'System', zh: '跟随系统' },
// Emergency Share Journey Page
emergencyContacts: { vi: 'Liên hệ khẩn cấp', en: 'Emergency Contacts', zh: '紧急联系人' },
tourOwner: { vi: 'Người tạo Tour (Owner)', en: 'Tour Owner', zh: '发起人' },
tourManager: { vi: 'Người quản lý (Manager)', en: 'Tour Manager', zh: '管理员' },
phoneNumber: { vi: 'Số điện thoại', en: 'Phone Number', zh: '电话号码' },
emergencyJourney: { vi: 'Hành trình Cứu hộ Khẩn cấp', en: 'Emergency Rescue Journey', zh: '紧急救援行程' },
noPhone: { vi: 'Không có số điện thoại', en: 'No phone number', zh: '暂无电话' },
noStops: { vi: 'Chưa có điểm dừng nào', en: 'No stops declared', zh: '暂无停留点' },
viewMap: { vi: 'Bản đồ', en: 'Map', zh: '地图' },
viewTimeline: { vi: 'Lịch trình', en: 'Itinerary', zh: '行程表' },
sharedJourneyTitle: { vi: 'Hành trình chia sẻ khẩn cấp', en: 'Emergency Shared Journey', zh: '紧急分享行程' },
linkExpired: { vi: 'Liên kết không tồn tại hoặc đã bị vô hiệu hóa.', en: 'Link does not exist or has been disabled.', zh: '链接不存在或已被禁用。' },
// Landing Page Buttons Short
shortExplore: { vi: 'Khám phá', en: 'Explore', zh: '探索' },
shortCamera: { vi: 'Chụp ảnh', en: 'Camera', zh: '拍照' },
reportBusinessBtn: { vi: 'Báo cáo sai phạm', en: 'Report Violation', zh: '举报' },
blacklistTitle: { vi: 'Widget Blacklist', en: 'Blacklist Widget', zh: '黑名单' },
reportModalTitle: { vi: 'Báo cáo cơ sở không đàng hoàng', en: 'Report Dishonest Business', zh: '举报不良商家' },
businessName: { vi: 'Tên cơ sở/người dùng', en: 'Name', zh: '名称' },
businessPhone: { vi: 'Số điện thoại', en: 'Phone Number', zh: '电话号码' },
businessEmail: { vi: 'Email liên hệ', en: 'Email Address', zh: '电子邮箱' },
businessAddress: { vi: 'Địa chỉ', en: 'Address', zh: '地址' },
businessType: { vi: 'Loại hình', en: 'Type', zh: '类型' },
typeUser: { vi: 'Người dùng', en: 'User', zh: '用户' },
typeRestaurant: { vi: 'Nhà hàng', en: 'Restaurant', zh: '餐厅' },
typeHotel: { vi: 'Khách sạn', en: 'Hotel', zh: '酒店' },
typeHomestay: { vi: 'Homestay', en: 'Homestay', zh: '民俗' },
reportReason: { vi: 'Lý do báo cáo', en: 'Reason for report', zh: '举报原因' },
submitReport: { vi: 'Gửi báo cáo', en: 'Submit Report', zh: '提交举报' },
reportSuccess: { vi: 'Gửi báo cáo thành công. Ban quản trị sẽ kiểm duyệt thông tin.', en: 'Report submitted successfully. The admin will review it.', zh: '提交成功。管理员将审核' },
emptyBlacklist: { vi: 'Chưa có cơ sở nào trong danh sách đen.', en: 'No businesses in blacklist yet.', zh: '黑名单中暂无商家。' },
tabReports: { vi: 'Blacklist', en: 'Blacklist', zh: '黑名单' },
// Modal titles and messages
areYouSure: { vi: 'Bạn có chắc chắn không?', en: 'Are you sure?', zh: '你确定吗?' },
processing: { vi: 'Đang xử lý...', en: 'Processing...', zh: '处理中...' },
checkingImages: { vi: 'Đang kiểm tra và lọc hình ảnh của bạn...', en: 'Checking and filtering your images...', zh: '正在检查和过滤您的图片...' },
uploadFailed: { vi: 'Tải ảnh thất bại.', en: 'Image upload failed.', zh: '图片上传失败。' },
uploadSuccess: { vi: 'Đã tải lên thành công.', en: 'Upload successful.', zh: '上传成功。' },
imageBlocked: { vi: 'Ảnh chứa nội dung không phù hợp và bị chặn.', en: 'Image contains inappropriate content and was blocked.', zh: '图片包含不当内容并已被屏蔽。' },
selectImage: { vi: 'Nhấn để chọn ảnh', en: 'Click to select images', zh: '点击选择图片' },
uploadPhotos: { vi: 'Tải ảnh lên', en: 'Upload Photos', zh: '上传照片' },
selectedFiles: { vi: 'Đã chọn', en: 'Selected', zh: '已选择' },
// Photo modals
imageModeration: { vi: 'Ảnh bị từ chối', en: 'Image Rejected', zh: '图片被拒' },
imageModerationBlocked: { vi: 'ảnh chứa nội dung không phù hợp và bị chặn.', en: 'image contains inappropriate content and was blocked.', zh: '图片包含不当内容并已被屏蔽。' },
imageCheck: { vi: 'Lỗi ảnh', en: 'Image Error', zh: '图片错误' },
imageLoadFailed: { vi: 'Không thể đọc nội dung ảnh: ', en: 'Cannot read image content: ', zh: '无法读取图片内容:' },
pleaseTryAgain: { vi: 'Vui lòng kiểm tra lại file.', en: 'Please check the file.', zh: '请检查文件。' },
fileCheckingError: { vi: 'Lỗi trong quá trình kiểm duyệt ảnh.', en: 'Error during image review.', zh: '图片审核过程中出错。' },
// Create Tour Modal
createTourModalTitle: { vi: 'Tạo Tour mới', en: 'Create New Tour', zh: '创建新行程' },
creatingTour: { vi: 'Đang tạo...', en: 'Creating...', zh: '创建中...' },
confirmCreateTour: { vi: 'Xác nhận tạo Tour', en: 'Confirm Tour Creation', zh: '确认创建行程' },
enterTourName: { vi: 'Nhập tên tour...', en: 'Enter tour name...', zh: '输入行程名称...' },
tourNameRequired: { vi: 'Tên tour không được để trống', en: 'Tour name cannot be empty', zh: '行程名称不能为空' },
// Add Location Modal
confirmStartPoint: { vi: 'Xác nhận Điểm xuất phát', en: 'Confirm Starting Point', zh: '确认起点' },
confirmEndPoint: { vi: 'Xác nhận Điểm kết thúc', en: 'Confirm Ending Point', zh: '确认终点' },
location: { vi: 'Vị trí', en: 'Location', zh: '位置' },
// Expense Manager
expenseReportTitle: { vi: 'BÁO CÁO CHI PHÍ TOUR', en: 'TOUR EXPENSE REPORT', zh: '行程费用报告' },
expenseSplitTable: { vi: 'Bảng phân chia chi phí', en: 'Expense Split Table', zh: '费用分割表' },
member: { vi: 'Thành viên', en: 'Member', zh: '成员' },
shouldPay: { vi: 'Cần trả', en: 'Should Pay', zh: '应付' },
paid: { vi: 'Đã trả', en: 'Paid', zh: '已付' },
balance: { vi: 'Số dư', en: 'Balance', zh: '余额' },
// Members Management
mergeMembers: { vi: 'Hợp nhất thành viên', en: 'Merge Members', zh: '合并成员' },
mergeSuccess: { vi: 'Hợp nhất thành công.', en: 'Merge successful.', zh: '合并成功。' },
mergeFailed: { vi: 'Hợp nhất thất bại.', en: 'Merge failed.', zh: '合并失败。' },
// Dashboard / User interactions
disconnect: { vi: 'Hủy kết nối', en: 'Disconnect', zh: '断开连接' },
notificationsMuted: { vi: 'Đã tắt thông báo', en: 'Notifications muted', zh: '通知已关闭' },
searchContent: { vi: 'Tìm kiếm nội dung...', en: 'Search content...', zh: '搜索内容...' },
// Notes Page
noNotesYet: { vi: 'Chưa có ghi chú nào', en: 'No notes yet', zh: '还没有笔记' },
createNewNote: { vi: 'Tạo ghi chú mới', en: 'Create New Note', zh: '创建新笔记' },
noteTitle: { vi: 'Tiêu đề', en: 'Title', zh: '标题' },
noteContent: { vi: 'Nội dung', en: 'Content', zh: '内容' },
deleteNote: { vi: 'Xóa ghi chú', en: 'Delete Note', zh: '删除笔记' },
deleteNoteConfirm: { vi: 'Bạn có chắc chắn muốn xóa ghi chú này?', en: 'Are you sure you want to delete this note?', zh: '确定要删除此笔记吗?' },
// Signup Page
createAccount: { vi: 'Tạo tài khoản mới', en: 'Create New Account', zh: '创建新账户' },
verifyAccount: { vi: 'Xác thực tài khoản', en: 'Verify Account', zh: '验证账户' },
confirmPassword: { vi: 'Xác nhận mật khẩu', en: 'Confirm Password', zh: '确认密码' },
passwordMismatch: { vi: 'Mật khẩu không khớp', en: 'Passwords do not match', zh: '密码不匹配' },
firstName: { vi: 'Tên', en: 'First Name', zh: '名字' },
lastName: { vi: 'Họ', en: 'Last Name', zh: '姓氏' },
// Tour Detail Page
tourMembers: { vi: 'Thành viên', en: 'Members', zh: '成员' },
expenses: { vi: 'Chi phí', en: 'Expenses', zh: '费用' },
errorLoadingTour: { vi: 'Lỗi khi tải thông tin tour.', en: 'Error loading tour information.', zh: '加载行程信息出错。' },
// Explore Map
restaurant: { vi: 'Nhà hàng', en: 'Restaurant', zh: '餐厅' },
hotel: { vi: 'Khách sạn', en: 'Hotel', zh: '酒店' },
homestay: { vi: 'Homestay', en: 'Homestay', zh: '民宿' },
// User Management Modal
businessTypeLabel: { vi: 'Loại hình kinh doanh', en: 'Business Type', zh: '商家类型' },
// Add Member Modal
searchMember: { vi: 'Tìm kiếm thành viên...', en: 'Search members...', zh: '搜索成员...' },
addMemberTitle: { vi: 'Thêm thành viên', en: 'Add Member', zh: '添加成员' },
// Comment Modal
comments: { vi: 'Bình luận', en: 'Comments', zh: '评论' },
addComment: { vi: 'Thêm bình luận', en: 'Add comment', zh: '添加评论' },
writeComment: { vi: 'Viết bình luận...', en: 'Write a comment...', zh: '写评论...' },
noComments: { vi: 'Chưa có bình luận nào', en: 'No comments yet', zh: '还没有评论' },
deleteComment: { vi: 'Xóa bình luận', en: 'Delete comment', zh: '删除评论' },
editComment: { vi: 'Chỉnh sửa bình luận', en: 'Edit comment', zh: '编辑评论' },
// General messages
loading_msg: { vi: 'Đang tải...', en: 'Loading...', zh: '加载中...' },
noData: { vi: 'Không có dữ liệu', en: 'No data', zh: '无数据' },
retry: { vi: 'Thử lại', en: 'Retry', zh: '重试' },
back: { vi: 'Quay lại', en: 'Back', zh: '返回' },
next: { vi: 'Tiếp theo', en: 'Next', zh: '下一步' },
previous: { vi: 'Trước đó', en: 'Previous', zh: '上一步' },
done: { vi: 'Xong', en: 'Done', zh: '完成' },
finish: { vi: 'Kết thúc', en: 'Finish', zh: '完成' },
submit: { vi: 'Gửi', en: 'Submit', zh: '提交' },
update: { vi: 'Cập nhật', en: 'Update', zh: '更新' },
create: { vi: 'Tạo', en: 'Create', zh: '创建' },
new: { vi: 'Mới', en: 'New', zh: '新建' },
// Dashboard - Connections & Friends
friendsList: { vi: 'Danh sách bạn bè', en: 'Friends List', zh: '好友列表' },
familyGroup: { vi: 'Gia đình', en: 'Family', zh: '家庭' },
friends: { vi: 'Bạn bè', en: 'Friends', zh: '朋友' },
manageFriendsDesc: { vi: 'Bạn bè, gia đình, yêu cầu chờ duyệt', en: 'Friends, family, pending requests', zh: '朋友、家人、待决批准' },
noConnections: { vi: 'Bạn chưa kết nối với ai. Hãy chuyển sang tìm kiếm để gửi lời mời.', en: 'You have no connections yet. Search to send invitations.', zh: '你还没有任何连接。搜索以发送邀请。' },
searchMembers: { vi: 'Tìm kiếm theo Tên hoặc Email (nhập tối thiểu 2 ký tự)...', en: 'Search by Name or Email (min 2 characters)...', zh: '按名称或电子邮件搜索(最少2个字符)...' },
searching: { vi: 'Đang tìm kiếm...', en: 'Searching...', zh: '搜索中...' },
minCharsRequired: { vi: 'Vui lòng nhập tối thiểu 2 ký tự để tìm kiếm thành viên.', en: 'Please enter at least 2 characters to search.', zh: '请输入至少2个字符进行搜索。' },
disconnectTitle: { vi: 'Hủy kết nối', en: 'Disconnect', zh: '断开连接' },
disconnectConfirm: { vi: 'Bạn có chắc chắn muốn hủy kết nối với', en: 'Are you sure you want to disconnect with', zh: '你确定要与...断开连接吗' },
disconnectSuccess: { vi: 'Đã hủy kết nối thành công.', en: 'Disconnected successfully.', zh: '断开连接成功。' },
searchError: { vi: 'Lỗi tìm kiếm thành viên:', en: 'Error searching members:', zh: '搜索成员时出错:' },
disconnectError: { vi: 'Lỗi hủy kết nối:', en: 'Error disconnecting:', zh: '断开连接出错:' },
// Dashboard - Photo Gallery
photoGallery: { vi: 'Thư viện ảnh', en: 'Photo Gallery', zh: '相册' },
photoGalleryDesc: { vi: 'Kho ảnh gốc của bạn từ các tour', en: 'Your original photos from all tours', zh: '您来自所有行程的原始照片' },
flagPhoto: { vi: 'Báo cáo ảnh', en: 'Report Photo', zh: '举报照片' },
flagPhotoReason: { vi: 'Lý do báo cáo', en: 'Report Reason', zh: '举报原因' },
flagPhotoSuccess: { vi: 'Đã báo cáo ảnh', en: 'Photo reported', zh: '已举报照片' },
flagPhotoError: { vi: 'Lỗi báo cáo ảnh', en: 'Error reporting photo', zh: '举报照片出错' },
inappropriate: { vi: 'Nội dung không phù hợp', en: 'Inappropriate content', zh: '不恰当的内容' },
spam: { vi: 'Spam', en: 'Spam', zh: '垃圾邮件' },
copyright: { vi: 'Vi phạm bản quyền', en: 'Copyright violation', zh: '侵犯版权' },
other: { vi: 'Khác', en: 'Other', zh: '其他' },
// Dashboard - Tours Management
toursManagement: { vi: 'Quản lý hành trình', en: 'Manage Tours', zh: '管理行程' },
toursManagementDesc: { vi: 'Xem và quản lý các chuyến đi', en: 'View and manage your travels', zh: '查看和管理您的旅行' },
// Dashboard - Notifications
notificationsEnabled: { vi: 'Đã bật thông báo', en: 'Notifications enabled', zh: '已启用通知' },
notificationsDisabled: { vi: 'Đã tắt thông báo', en: 'Notifications disabled', zh: '已禁用通知' },
emergencySharingEnabled: { vi: 'Đã bật chia sẻ hành trình cứu hộ.', en: 'Emergency sharing enabled.', zh: '已启用紧急分享。' },
emergencySharingDisabled: { vi: 'Đã tắt chia sẻ.', en: 'Sharing disabled.', zh: '已禁用分享。' },
// Dashboard - Connection Types
changeConnectionType: { vi: 'Mối quan hệ đã được chuyển sang nhóm: ', en: 'Relationship changed to group: ', zh: '关系已更改为组:' },
// Dashboard - Sections
settingsSection: { vi: 'Cài đặt', en: 'Settings', zh: '设置' },
manageRelations: { vi: 'Quản lý các mối quan hệ bạn bè, gia đình, duyệt các yêu cầu kết nối từ thành viên khác.', en: 'Manage friends, family relationships, and review connection requests from other members.', zh: '管理朋友和家人关系,审查来自其他成员的连接请求。' }
};
export const useTranslation = () => {
const [lang, setLang] = useState<Language>(() => {
return (localStorage.getItem('language') as Language) || 'vi';
});
const changeLanguage = (newLang: Language) => {
localStorage.setItem('language', newLang);
setLang(newLang);
// Dispatch custom event to sync across components
window.dispatchEvent(new Event('languageChange'));
};
useEffect(() => {
const handleLangChange = () => {
setLang((localStorage.getItem('language') as Language) || 'vi');
};
window.addEventListener('languageChange', handleLangChange);
return () => window.removeEventListener('languageChange', handleLangChange);
}, []);
const t = (key: string): string => {
if (!translations[key]) {
return key;
}
return translations[key][lang];
};
return { t, lang, changeLanguage };
};
+194
View File
@@ -24,4 +24,198 @@
max-width: 150px;
}
}
}
@keyframes bell-ring {
0% { transform: rotate(0); }
5% { transform: rotate(30deg); }
10% { transform: rotate(-28deg); }
15% { transform: rotate(34deg); }
20% { transform: rotate(-32deg); }
25% { transform: rotate(30deg); }
30% { transform: rotate(-28deg); }
35% { transform: rotate(26deg); }
40% { transform: rotate(-24deg); }
45% { transform: rotate(22deg); }
50% { transform: rotate(-20deg); }
55% { transform: rotate(18deg); }
60% { transform: rotate(-16deg); }
65% { transform: rotate(14deg); }
70% { transform: rotate(-12deg); }
75% { transform: rotate(10deg); }
80% { transform: rotate(-8deg); }
85% { transform: rotate(6deg); }
90% { transform: rotate(-4deg); }
95% { transform: rotate(2deg); }
100% { transform: rotate(0); }
}
.animate-ring {
display: inline-block !important;
transform-origin: top center !important;
transform-box: fill-box !important;
animation: bell-ring 1.5s ease-in-out infinite !important;
}
/* Light theme overrides for Member Dashboard */
html.light body,
html.light .app-container {
background-color: #f8fafc;
color: #334155;
}
html.light .bg-slate-950 {
background-color: #f8fafc !important;
color: #1e293b !important;
}
html.light .bg-slate-900 {
background-color: #f1f5f9 !important;
color: #1e293b !important;
}
html.light .bg-slate-900\/60 {
background-color: rgba(255, 255, 255, 0.7) !important;
border-color: #e2e8f0 !important;
}
html.light .bg-slate-900\/30 {
background-color: rgba(255, 255, 255, 0.4) !important;
border-color: #e2e8f0 !important;
}
html.light .bg-slate-950\/40 {
background-color: #f1f5f9 !important;
}
html.light .text-white,
html.light .text-white\/95,
html.light .text-slate-100 {
color: #0f172a !important;
}
html.light .text-slate-400,
html.light .text-slate-350,
html.light .text-slate-300 {
color: #475569 !important;
}
html.light .border-slate-800,
html.light .border-slate-800\/60,
html.light .border-slate-800\/80,
html.light .border-slate-900,
html.light .border-slate-700\/50 {
border-color: #cbd5e1 !important;
}
html.light .hover\:bg-slate-800\/50:hover {
background-color: rgba(226, 232, 240, 0.5) !important;
}
html.light .hover\:bg-slate-850:hover,
html.light .hover\:bg-slate-800:hover,
html.light .hover\:bg-white\/10:hover,
html.light .hover\:bg-white\/5:hover {
background-color: #e2e8f0 !important;
color: #0f172a !important;
}
/* Chat bubble styling overrides */
html.light .bg-slate-850\/50 {
background-color: rgba(241, 245, 249, 0.5) !important;
}
html.light .bg-slate-850 {
background-color: #f1f5f9 !important;
color: #0f172a !important;
}
html.light .bg-slate-800\/60 {
background-color: rgba(226, 232, 240, 0.6) !important;
}
html.light .bg-slate-800\/40 {
background-color: rgba(226, 232, 240, 0.4) !important;
}
html.light .bg-slate-950\/60 {
background-color: rgba(255, 255, 255, 0.8) !important;
}
html.light .bg-rose-950\/10 {
background-color: #fef2f2 !important;
border-color: #fee2e2 !important;
}
html.light .text-rose-400 {
color: #dc2626 !important;
}
html.light .bg-rose-950\/40 {
background-color: #fee2e2 !important;
}
html.light .border-rose-900\/50 {
border-color: #fecaca !important;
}
/* Additional light mode overrides for MemberDashboard and complex classes */
html.light [class*="bg-slate-800"] {
background-color: #f0f1f5 !important;
color: #0f172a !important;
}
html.light [class*="bg-slate-900"] {
background-color: #f1f5f9 !important;
color: #0f172a !important;
}
html.light [class*="text-slate-400"],
html.light [class*="text-slate-350"],
html.light [class*="text-slate-300"] {
color: #475569 !important;
}
html.light [class*="border-slate-900"],
html.light [class*="border-slate-800"] {
border-color: #cbd5e1 !important;
}
html.light .hover\:bg-slate-900:hover {
background-color: #e2e8f0 !important;
color: #0f172a !important;
}
html.light .hover\:bg-white:hover {
background-color: #f8fafc !important;
}
/* Ensure text contrast in light mode */
html.light .text-white {
color: #0f172a !important;
}
html.light .text-gray-50 {
color: #0f172a !important;
}
html.light .text-gray-100 {
color: #1e293b !important;
}
/* Tailwind Dark Mode - ensure dark classes work when in dark theme */
html.dark .dark\:bg-slate-800 {
background-color: #1e293b !important;
}
html.dark .dark\:text-slate-200 {
color: #e2e8f0 !important;
}
html.dark .dark\:border-slate-700 {
border-color: #334155 !important;
}
html.dark .dark\:hover\:bg-slate-700:hover {
background-color: #334155 !important;
}
+68
View File
@@ -0,0 +1,68 @@
import React, { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { UserManagementModal } from '../components/UserManagementModal';
interface AdminDashboardProps {
user: any;
onNavigate: (page: string) => void;
}
export const AdminDashboard: React.FC<AdminDashboardProps> = ({ user, onNavigate }) => {
const [isModalOpen, setIsModalOpen] = useState(true);
if (!user?.isAdmin) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
<div className="text-center">
<h1 className="text-3xl font-black text-white mb-4">Quyền Truy Cập Bị Từ Chối</h1>
<p className="text-slate-400 mb-6">Bạn không quyền truy cập trang admin này.</p>
<button
onClick={() => onNavigate('dashboard')}
className="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-bold transition-all"
>
Quay lại Dashboard
</button>
</div>
</div>
);
}
// When admin closes the modal, navigate back to user dashboard
const handleCloseModal = () => {
onNavigate('dashboard');
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* Header with toggle button */}
<div className="fixed top-0 left-0 right-0 z-[60] bg-slate-900/95 backdrop-blur-md border-b border-slate-800 px-4 md:px-6 py-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<button
onClick={handleCloseModal}
className="p-2 hover:bg-slate-800 rounded-xl transition-colors"
title="Quay lại User Dashboard"
>
<ArrowLeft className="w-6 h-6 text-slate-300" />
</button>
<h1 className="text-2xl font-black text-white">
🛡 Admin Dashboard
</h1>
</div>
<button
onClick={handleCloseModal}
className="px-4 py-2 text-sm font-bold text-slate-300 hover:text-white hover:bg-slate-800 rounded-xl transition-all"
>
Switch to User Dashboard
</button>
</div>
{/* Modal shown full screen */}
<div className="pt-20">
<UserManagementModal
isOpen={isModalOpen}
onClose={handleCloseModal}
/>
</div>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More