Compare commits

...

68 Commits

Author SHA1 Message Date
df16a76909 fix(security): resolve audit findings — debug log, payment bypass, images
- Remove the committed firebase-debug.log (contains a personal Google
  account) and gitignore it. NOTE: still present in history — a history
  rewrite or written confirmation is still outstanding
- Remove the client-side skipPaymentCheck approval bypass
- Replace the wildcard image remote host "**" with an explicit allowlist
  (adds DigitalOcean Spaces)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:30:48 +05:30
pradeepkumar
2e2e7f0ae2 chore: increase user pagination limit from 10 to 50 in dashboard page 2026-05-05 22:53:44 +05:30
pradeepkumar
196fee71ae feat: add audit log dashboard with filtering and service integration 2026-04-28 11:12:59 +05:30
pradeepkumar
4895aed55d fix: improve expertise input handling to prevent character loss during comma-separated editing 2026-04-24 13:08:34 +05:30
pradeepkumar
bfba41b4fa feat: add subscription status display and approval confirmation modal for unpaid agents 2026-04-20 23:38:12 +05:30
pradeepkumar
d0579a19fc feat: include agent IDs in ProfessionalItem and FeaturedAgentItem interfaces and dashboard updates 2026-04-20 10:32:00 +05:30
pradeepkumar
91af420e08 feat: add subscription plans management page with Stripe synchronization functionality 2026-04-17 18:58:33 +05:30
pradeepkumar
da82e90b50 fix: format agent average rating to one decimal place 2026-04-16 07:42:43 +05:30
pradeepkumar
edb3c3f744 fix: remove expertise_areas from CMS dashboard slug configuration 2026-04-16 07:39:25 +05:30
pradeepkumar
2fdbd4280c feat: replace team members with cards in CMS, add date validation fields, and remove contact info section from user profile 2026-04-15 23:39:50 +05:30
pradeepkumar
974f5a863f fix: make role field optional in testimonial validation logic 2026-04-12 16:29:23 +05:30
pradeepkumar
fab8721f0f feat: add slug field to profile section edit modal and update service DTO 2026-04-11 23:06:23 +05:30
pradeepkumar
5cb9dcfb0e refactor: remove redundant agent profile section and consolidate agent details into the dynamic profile view 2026-04-10 16:39:53 +05:30
pradeepkumar
25911c7cb9 fix 2026-04-08 04:15:43 +05:30
pradeepkumar
9b750ac846 fix 2026-04-08 04:12:40 +05:30
pradeepkumar
fbd2214e4c fix 2026-04-07 20:14:44 +05:30
pradeepkumar
b1999fa8af fix 2026-04-07 19:55:01 +05:30
pradeepkumar
a89c7319fb chore: update favicon asset 2026-04-07 19:44:37 +05:30
pradeepkumar
9a9651ba15 refactor: wrap fetchData call in an arrow function within the refresh button onClick handler 2026-04-07 13:17:21 +05:30
pradeepkumar
8f90e9c6bc fix 2026-04-07 11:48:38 +05:30
pradeepkumar
3f20d5f345 refactor: add silent mode to fetchData to prevent loading state UI flickers during background updates 2026-04-04 21:02:08 +05:30
pradeepkumar
d83ee39022 style: update user status toggle button styling and labels for improved visibility and clarity 2026-03-31 23:43:45 +05:30
pradeepkumar
6a5e541622 fix 2026-03-31 23:42:18 +05:30
pradeepkumar
f396c2e79d fix 2026-03-31 23:26:09 +05:30
pradeepkumar
1fa34c2a44 fix 2026-03-31 23:23:02 +05:30
pradeepkumar
7d17b1946f fix 2026-03-31 22:39:02 +05:30
pradeepkumar
7787f4b60f feat: fetch and integrate detailed agent field values for CMS profile selection 2026-03-30 14:49:00 +05:30
pradeepkumar
db225b299f refactor: decouple avatar display URLs from original S3 keys in agent search results 2026-03-30 11:58:59 +05:30
pradeepkumar
5a2b468af1 feat: resolve agent avatar presigned URLs and improve agent profile data mapping in CMS dashboard 2026-03-29 03:51:55 +05:30
pradeepkumar
7bffc99fcb feat: add contactRecords support to CMS record retrieval helper 2026-03-28 20:13:33 +05:30
pradeepkumar
c15c4939a2 refactor: improve user avatar rendering by always displaying initials and handling image load errors. 2026-03-27 15:36:47 +05:30
pradeepkumar
66bb5d116d feat: Enable selection of verified agents for CMS featured cards and update user search to filter by verification status. 2026-03-27 11:59:17 +05:30
pradeepkumar
429be1df99 fix 2026-03-26 18:30:41 +05:30
pradeepkumar
2e52821b63 feat: Implement featured agents section within the CMS features management interface. 2026-03-23 11:25:23 +05:30
pradeepkumar
2ad3f01887 feat: Add agent verification history display to the user detail page. 2026-03-21 08:56:17 +05:30
pradeepkumar
8b12878cfa feat: allow SUPER_ADMIN role for admin access and update modal background opacity class. 2026-03-20 17:15:52 +05:30
pradeepkumar
37326db652 feat: Implement admin user management page with create/delete functionality, add a super-admin-only sidebar link, and expose isSuperAdmin in AuthContext. 2026-03-20 17:04:59 +05:30
pradeepkumar
8043d66898 feat: add admin interface for viewing and managing contact messages, including a new service and sidebar navigation. 2026-03-20 12:43:56 +05:30
pradeepkumar
2c4ce1378a feat: Add full CMS support for the 'Contact' page, including sections, default content factories, data fetching, and editing forms. 2026-03-19 13:32:44 +05:30
pradeepkumar
f569e8bc7d feat: Add user reports management dashboard with status filtering, counts, and review functionality including admin notes. 2026-03-19 05:20:10 +05:30
pradeepkumar
0904402a4b feat: Implement real-time unread support chat count in the sidebar using WebSockets. 2026-03-19 04:20:33 +05:30
pradeepkumar
69e04c46f9 chore: Update login email input placeholder text from admin@realestate.com to admin@re-quest.com. 2026-03-18 16:48:56 +05:30
pradeepkumar
1b8da0f58a feat: add admin subscriptions management page with new Stripe service and sidebar navigation. 2026-03-07 10:10:15 +05:30
pradeepkumar
6e9be5da9e feat: Add admin support chat page and service for managing user conversations. 2026-03-05 06:37:19 +05:30
pradeepkumar
22c9db526d feat: Return S3 keys from image uploads and resolve them for display previews in the CMS. 2026-03-05 05:51:45 +05:30
pradeepkumar
24401fa33d fix: improve handling of empty number input for the rating field in CMS. 2026-03-05 05:39:17 +05:30
pradeepkumar
8941bb79c7 feat: Add field validation and UI configuration management, and enable editing of field assignments. 2026-02-24 04:05:47 +05:30
pradeepkumar
79645a70bc feat: Add FAQ content management page with functionality to manage categories and items. 2026-02-24 03:53:56 +05:30
pradeepkumar
a7a2f53ada feat: Replace feature icon path text input with an image upload field. 2026-02-24 03:39:19 +05:30
pradeepkumar
6ec61f802e feat: Add About Us page CMS functionality with dedicated sections and image upload support. 2026-02-24 03:30:35 +05:30
pradeepkumar
827cac0454 feat: add agent search and selection functionality with a 3-item limit to the CMS professional editor 2026-02-24 03:16:56 +05:30
pradeepkumar
3297469bcc feat: add CMS page and service to manage website content. 2026-02-22 21:52:05 +05:30
pradeepkumar
a7acd2f48c feat: add action icons to table row actions and refine focus styles for various form elements. 2026-02-11 03:34:12 +05:30
pradeepkumar
33bd968d50 feat: Add Source Serif 4 and Fractul font families and integrate them across the admin UI. 2026-02-11 03:22:06 +05:30
pradeepkumar
41df986d77 feat: Improve sort order input display by showing an empty string for zero values and adding a placeholder. 2026-02-09 00:03:53 +05:30
pradeepkumar
0284fb7c41 feat: Add onFocus select and min attribute to number input fields across various forms for improved usability. 2026-02-06 06:58:56 +05:30
pradeepkumar
c90155149a feat: Display presigned avatar URLs for recent users on the dashboard. 2026-02-01 15:04:11 +05:30
pradeepkumar
51469ada6d feat: Update document download logic to use id as a fallback for the S3 key and refine the VerificationDocument interface. 2026-01-29 00:02:45 +05:30
pradeepkumar
fa29bb1064 feat: Implement agent verification management, including document display and status updates, on the user detail page. 2026-01-28 13:48:02 +05:30
pradeepkumar
6591120626 feat: Implement user detail page with agent type management and navigation from the user list. 2026-01-26 23:12:04 +05:30
pradeepkumar
a061b14914 feat: Implement S3 presigned URL fetching for user avatars and add an image proxy utility. 2026-01-26 22:43:41 +05:30
pradeepkumar
4962f9a625 styleing issue 2026-01-24 22:23:15 +05:30
pradeepkumar
942c757f13 fix 2026-01-24 21:36:36 +05:30
pradeepkumar
7ff67a1f8d fix 2026-01-24 21:06:18 +05:30
pradeepkumar
f2598efddf style: improve styling for agent type sections list and draggable items. 2026-01-24 20:37:12 +05:30
pradeepkumar
66e0b4659d update profile section 2026-01-24 15:33:11 +05:30
pradeepkumar
ebbaaa84eb feat: Add isSystem property to profile sections and isSearchableOnly to profile fields, including UI updates for display and management. 2026-01-24 12:49:34 +05:30
pradeepkumar
82a8b334ba fix 2026-01-24 03:33:48 +05:30
130 changed files with 9155 additions and 179 deletions

2
.gitignore vendored
View File

@@ -29,6 +29,7 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
firebase-debug.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
@@ -39,3 +40,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
CLAUDE.md

View File

@@ -11,7 +11,7 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
# Install dependencies # Install dependencies
RUN npm ci RUN npm ci --legacy-peer-deps
# Stage 2: Build # Stage 2: Build
FROM node:20-alpine AS builder FROM node:20-alpine AS builder

View File

@@ -7,10 +7,9 @@ const nextConfig: NextConfig = {
// Image optimization // Image optimization
images: { images: {
remotePatterns: [ remotePatterns: [
{ { protocol: "https", hostname: "*.contabostorage.com" },
protocol: "https", { protocol: "https", hostname: "*.amazonaws.com" },
hostname: "**", { protocol: "https", hostname: "*.digitaloceanspaces.com" },
},
], ],
}, },
}; };

152
package-lock.json generated
View File

@@ -11,7 +11,8 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"next": "16.1.0", "next": "16.1.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"socket.io-client": "^4.8.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -21,7 +22,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.0", "eslint-config-next": "16.1.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "5.9.3"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -1234,6 +1235,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1466,6 +1473,66 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@@ -2726,7 +2793,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -2843,6 +2909,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.4", "version": "5.18.4",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
@@ -5004,7 +5092,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@@ -5909,6 +5996,34 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6579,6 +6694,35 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -12,7 +12,8 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"next": "16.1.0", "next": "16.1.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"socket.io-client": "^4.8.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -22,6 +23,6 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.0", "eslint-config-next": "16.1.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "5.9.3"
} }
} }

View File

@@ -0,0 +1,211 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useRouter } from 'next/navigation';
import api, { ApiResponse, getErrorMessage } from '@/services/api';
interface AdminUser {
id: string;
email: string;
role: string;
status: string;
createdAt: string;
lastLoginAt: string | null;
}
export default function AdminUsersPage() {
const { user, isSuperAdmin } = useAuth();
const router = useRouter();
const [admins, setAdmins] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
// Redirect non-super-admins
useEffect(() => {
if (!isSuperAdmin) {
router.push('/dashboard');
}
}, [isSuperAdmin, router]);
const fetchAdmins = async () => {
try {
setLoading(true);
const response = await api.get<ApiResponse<AdminUser[]>>('/users/admin/admins');
setAdmins(response.data.data);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
};
useEffect(() => { if (isSuperAdmin) fetchAdmins(); }, [isSuperAdmin]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newEmail.trim() || !newPassword.trim()) return;
setCreating(true);
setError('');
try {
await api.put('/users/admin/admins', { email: newEmail.trim(), password: newPassword });
setShowCreate(false);
setNewEmail('');
setNewPassword('');
fetchAdmins();
} catch (err) {
setError(getErrorMessage(err));
} finally {
setCreating(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this admin?')) return;
setDeletingId(id);
try {
await api.delete(`/users/admin/admins/${id}`);
setAdmins(prev => prev.filter(a => a.id !== id));
} catch (err) {
setError(getErrorMessage(err));
} finally {
setDeletingId(null);
}
};
if (!isSuperAdmin) return null;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Admin Users</h1>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-[#f5a623] text-white text-sm font-medium rounded-lg hover:bg-[#e09520] transition-colors"
>
+ Create Admin
</button>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg mb-4">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{admins.map(admin => (
<tr key={admin.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-900">{admin.email}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
admin.role === 'SUPER_ADMIN' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'
}`}>
{admin.role === 'SUPER_ADMIN' ? 'Super Admin' : 'Admin'}
</span>
</td>
<td className="px-4 py-3">
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
{admin.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{new Date(admin.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
{admin.role === 'ADMIN' && admin.id !== user?.id ? (
<button
onClick={() => handleDelete(admin.id)}
disabled={deletingId === admin.id}
className="text-sm text-red-600 hover:text-red-800 disabled:opacity-50"
>
{deletingId === admin.id ? 'Deleting...' : 'Delete'}
</button>
) : (
<span className="text-sm text-gray-400"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Create Admin Modal */}
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Create Admin User</h3>
</div>
<form onSubmit={handleCreate}>
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
value={newEmail}
onChange={e => setNewEmail(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900 bg-white placeholder:text-gray-500"
placeholder="admin@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900 bg-white placeholder:text-gray-500"
placeholder="Min 8 characters"
/>
</div>
</div>
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
<button
type="button"
onClick={() => { setShowCreate(false); setNewEmail(''); setNewPassword(''); }}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
>
Cancel
</button>
<button
type="submit"
disabled={creating}
className="px-4 py-2 bg-[#f5a623] text-white rounded-lg hover:bg-[#e09520] text-sm disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Admin'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,349 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import { profileSectionsService, AgentTypeSectionsResponse, SectionOrderItem, getErrorMessage } from '@/services';
interface SectionWithOrder {
id: string;
name: string;
slug: string;
description: string | null;
icon: string | null;
sortOrder: number;
isActive: boolean;
isGlobal: boolean;
isSystem: boolean;
isRequired: boolean;
effectiveSortOrder: number;
hasCustomOrder: boolean;
_count?: {
fields: number;
};
}
export default function AgentTypeSectionsPage() {
const router = useRouter();
const params = useParams();
const agentTypeId = params.id as string;
const [data, setData] = useState<AgentTypeSectionsResponse | null>(null);
const [sections, setSections] = useState<SectionWithOrder[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const [hasChanges, setHasChanges] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const fetchData = useCallback(async () => {
setIsLoading(true);
setError('');
try {
const response = await profileSectionsService.getSectionsForAgentType(agentTypeId, false);
setData(response);
setSections(response.sections as SectionWithOrder[]);
setHasChanges(false);
} catch (err) {
const errorMessage = getErrorMessage(err);
setError(errorMessage);
if (errorMessage.includes('Unauthorized')) {
router.push('/login');
}
} finally {
setIsLoading(false);
}
}, [agentTypeId, router]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
const newSections = [...sections];
const draggedItem = newSections[draggedIndex];
newSections.splice(draggedIndex, 1);
newSections.splice(index, 0, draggedItem);
// Update effective sort orders
newSections.forEach((section, idx) => {
section.effectiveSortOrder = idx;
});
setSections(newSections);
setDraggedIndex(index);
setHasChanges(true);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
const moveSection = (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= sections.length) return;
const newSections = [...sections];
const temp = newSections[index];
newSections[index] = newSections[newIndex];
newSections[newIndex] = temp;
// Update effective sort orders
newSections.forEach((section, idx) => {
section.effectiveSortOrder = idx;
});
setSections(newSections);
setHasChanges(true);
};
const toggleRequired = (index: number) => {
const newSections = [...sections];
newSections[index] = {
...newSections[index],
isRequired: !newSections[index].isRequired,
};
setSections(newSections);
setHasChanges(true);
};
const handleSave = async () => {
setIsSaving(true);
setError('');
try {
const sectionOrders: SectionOrderItem[] = sections.map((section, index) => ({
sectionId: section.id,
sortOrder: index,
isRequired: section.isRequired,
}));
await profileSectionsService.updateSectionOrderForAgentType(agentTypeId, sectionOrders);
setHasChanges(false);
// Refresh to get updated hasCustomOrder flags
await fetchData();
} catch (err) {
setError(getErrorMessage(err));
} finally {
setIsSaving(false);
}
};
const handleReset = () => {
fetchData();
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
</div>
);
}
return (
<div>
{/* Breadcrumb */}
<div className="mb-6">
<nav className="flex items-center space-x-2 text-sm text-[#666666]">
<Link href="/dashboard/agent-types" className="hover:text-[#f5a623]">
Agent Types
</Link>
<span>/</span>
<span className="text-[#00293d] font-medium">{data?.agentType.name || 'Loading...'}</span>
<span>/</span>
<span className="text-[#00293d]">Sections</span>
</nav>
</div>
{/* Page Title */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">
Manage Sections for {data?.agentType.name}
</h1>
<p className="text-[#666666] font-serif">
Customize the order and requirements of profile sections for this agent type.
Drag to reorder or use the arrow buttons.
</p>
</div>
{/* Sections List */}
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb]">
<div className="px-6 py-4 border-b border-[#e5e7eb]">
<div className="flex justify-between items-center">
<div className="flex items-center space-x-4">
<span className="text-sm text-[#666666]">
{sections.length} section{sections.length !== 1 ? 's' : ''}
</span>
{hasChanges && (
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
Unsaved Changes
</span>
)}
</div>
<div className="flex space-x-3">
{hasChanges && (
<>
<button
onClick={handleReset}
disabled={isSaving}
className="px-4 py-2 border border-[#e5e7eb] text-[#00293d] rounded-xl hover:bg-[#f5f9f8] transition-colors disabled:opacity-50"
>
Reset
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 bg-[#f5a623] text-white rounded-xl hover:bg-[#e09620] transition-colors disabled:opacity-50 flex items-center"
>
{isSaving ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Saving...
</>
) : (
'Save Order'
)}
</button>
</>
)}
</div>
</div>
</div>
{error && (
<div className="px-6 py-4 bg-red-50 border-b border-red-200">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{sections.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-[#9ca3af]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p className="mt-2 text-[#666666] font-serif">No sections assigned to this agent type</p>
<Link
href="/dashboard/profile-sections"
className="mt-4 inline-block px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors"
>
Go to Profile Sections
</Link>
</div>
) : (
<div className="p-4 space-y-3">
{sections.map((section, index) => (
<div
key={section.id}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className={`bg-white rounded-xl shadow-sm border border-[#e5e7eb] p-4 flex items-center justify-between hover:shadow-md cursor-move transition-all ${
draggedIndex === index ? 'ring-2 ring-[#f5a623]/30 ring-offset-2 shadow-lg' : ''
}`}
>
<div className="flex items-center space-x-4">
{/* Drag Handle */}
<div className="text-[#9ca3af] cursor-grab">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm8-12a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0z" />
</svg>
</div>
{/* Order Number */}
<div className="w-8 h-8 flex items-center justify-center rounded-full bg-[#e8f0ee] text-[#666666] text-sm font-medium">
{index + 1}
</div>
{/* Section Info */}
<div>
<div className="flex items-center space-x-2">
<span className="font-medium text-[#00293d]">{section.name}</span>
{section.isGlobal && (
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-[#fff7ed] text-[#f5a623]">
Global
</span>
)}
{section.isSystem && (
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-[#e8f0ee] text-[#5ba4a4]">
System
</span>
)}
{section.hasCustomOrder && (
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
Custom Order
</span>
)}
</div>
<div className="text-sm text-[#666666]">
{section.description || 'No description'}
</div>
</div>
</div>
<div className="flex items-center space-x-4">
{/* Required Toggle */}
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={section.isRequired}
onChange={() => toggleRequired(index)}
className="rounded border-[#e5e7eb] text-[#f5a623] focus:ring-0"
/>
<span className="text-sm text-[#666666]">Required</span>
</label>
{/* Move Buttons */}
<div className="flex space-x-1">
<button
onClick={() => moveSection(index, 'up')}
disabled={index === 0}
className="p-1.5 text-[#9ca3af] hover:text-[#666666] hover:bg-[#e8f0ee] rounded disabled:opacity-30 disabled:cursor-not-allowed"
title="Move up"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
<button
onClick={() => moveSection(index, 'down')}
disabled={index === sections.length - 1}
className="p-1.5 text-[#9ca3af] hover:text-[#666666] hover:bg-[#e8f0ee] rounded disabled:opacity-30 disabled:cursor-not-allowed"
title="Move down"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Info Box */}
<div className="mt-6 p-4 bg-[#f5f9f8] rounded-xl border border-[#e5e7eb]">
<h3 className="text-sm font-semibold text-[#00293d] mb-2 font-fractul">How Section Ordering Works</h3>
<ul className="text-sm text-[#666666] space-y-1 font-serif">
<li><strong>Global sections</strong> (purple badge) are automatically included for all agent types.</li>
<li><strong>System sections</strong> (blue badge) cannot be deleted, only hidden.</li>
<li><strong>Custom Order</strong> (yellow badge) indicates this agent type has a custom order for that section.</li>
<li>Drag sections or use the arrow buttons to change the display order.</li>
<li>Mark sections as <strong>Required</strong> to enforce completion by agents.</li>
<li>Click <strong>Save Order</strong> to apply your changes.</li>
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,454 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { agentTypesService, AgentType, CreateAgentTypeDto, UpdateAgentTypeDto, getErrorMessage } from '@/services';
type ModalType = 'create' | 'edit' | 'delete' | null;
export default function AgentTypesPage() {
const router = useRouter();
const [agentTypes, setAgentTypes] = useState<AgentType[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [showInactive, setShowInactive] = useState(true);
// Modal state
const [modalType, setModalType] = useState<ModalType>(null);
const [selectedType, setSelectedType] = useState<AgentType | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [modalError, setModalError] = useState('');
// Form state
const [formData, setFormData] = useState<CreateAgentTypeDto>({
name: '',
description: '',
icon: '',
isActive: true,
sortOrder: 0,
});
useEffect(() => {
fetchAgentTypes();
}, [showInactive]);
const fetchAgentTypes = async () => {
setIsLoading(true);
setError('');
try {
const data = await agentTypesService.getAll(showInactive);
setAgentTypes(data);
} catch (err) {
const errorMessage = getErrorMessage(err);
setError(errorMessage);
if (errorMessage.includes('Unauthorized')) {
router.push('/login');
}
} finally {
setIsLoading(false);
}
};
const openCreateModal = () => {
setFormData({
name: '',
description: '',
icon: '',
isActive: true,
sortOrder: 0,
});
setModalError('');
setModalType('create');
};
const openEditModal = (agentType: AgentType) => {
setSelectedType(agentType);
setFormData({
name: agentType.name,
description: agentType.description || '',
icon: agentType.icon || '',
isActive: agentType.isActive,
sortOrder: agentType.sortOrder,
});
setModalError('');
setModalType('edit');
};
const openDeleteModal = (agentType: AgentType) => {
setSelectedType(agentType);
setModalError('');
setModalType('delete');
};
const closeModal = () => {
setModalType(null);
setSelectedType(null);
setModalError('');
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setModalError('');
try {
await agentTypesService.create(formData);
closeModal();
fetchAgentTypes();
} catch (err) {
setModalError(getErrorMessage(err));
} finally {
setIsSubmitting(false);
}
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedType) return;
setIsSubmitting(true);
setModalError('');
try {
await agentTypesService.update(selectedType.id, formData as UpdateAgentTypeDto);
closeModal();
fetchAgentTypes();
} catch (err) {
setModalError(getErrorMessage(err));
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async () => {
if (!selectedType) return;
setIsSubmitting(true);
setModalError('');
try {
await agentTypesService.delete(selectedType.id);
closeModal();
fetchAgentTypes();
} catch (err) {
setModalError(getErrorMessage(err));
} finally {
setIsSubmitting(false);
}
};
const toggleStatus = async (agentType: AgentType) => {
try {
await agentTypesService.update(agentType.id, { isActive: !agentType.isActive });
fetchAgentTypes();
} catch (err) {
setError(getErrorMessage(err));
}
};
return (
<div>
{/* Page Title */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">Agent Types</h1>
<p className="text-[#666666] font-serif">Manage agent categories and types</p>
</div>
{/* Agent Types Table */}
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb]">
<div className="px-6 py-4 border-b border-[#e5e7eb]">
<div className="flex justify-between items-center">
<div className="flex items-center space-x-4">
<label className="flex items-center space-x-2 text-sm text-[#666666] font-serif">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded border-[#e5e7eb] text-[#f5a623] focus:ring-0"
/>
<span>Show Inactive</span>
</label>
</div>
<div className="flex space-x-3">
<button
onClick={fetchAgentTypes}
className="px-4 py-2 border border-[#e5e7eb] hover:bg-[#f5f9f8] text-[#00293d] text-sm font-medium rounded-lg transition-colors flex items-center"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
<button
onClick={openCreateModal}
className="px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors flex items-center"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Agent Type
</button>
</div>
</div>
</div>
{error && (
<div className="px-6 py-4 bg-red-50 border-b border-red-200">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
<div className="overflow-x-auto">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
</div>
) : agentTypes.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-[#9ca3af]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p className="mt-2 text-[#666666]">No agent types found</p>
<button
onClick={openCreateModal}
className="mt-4 px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors"
>
Create First Agent Type
</button>
</div>
) : (
<table className="min-w-full divide-y divide-[#e5e7eb]">
<thead className="bg-[#f5f9f8]">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Description
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Icon
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Sort Order
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Agents
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-[#666666] uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-[#e5e7eb]">
{agentTypes.map((agentType) => (
<tr key={agentType.id} className="hover:bg-[#f5f9f8]">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-[#00293d]">{agentType.name}</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-[#666666] max-w-xs truncate">
{agentType.description || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-[#666666]">{agentType.icon || '-'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-[#666666]">{agentType.sortOrder}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => toggleStatus(agentType)}
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full cursor-pointer transition-colors ${
agentType.isActive
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}`}
>
{agentType.isActive ? 'Active' : 'Inactive'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-[#666666]">{agentType._count?.agents || 0}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => router.push(`/dashboard/agent-types/${agentType.id}/sections`)}
className="text-[#5ba4a4] hover:text-[#5ba4a4]/80 mr-3 inline-flex items-center gap-1"
title="Sections"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Sections
</button>
<button
onClick={() => openEditModal(agentType)}
className="text-[#f5a623] hover:text-[#e09620] mr-3 inline-flex items-center gap-1"
title="Edit"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
<button
onClick={() => openDeleteModal(agentType)}
className="text-red-600 hover:text-red-900 inline-flex items-center gap-1"
title="Delete"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Create/Edit Modal */}
{(modalType === 'create' || modalType === 'edit') && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-[#e5e7eb]">
<h3 className="text-lg font-semibold text-[#00293d]">
{modalType === 'create' ? 'Create Agent Type' : 'Edit Agent Type'}
</h3>
</div>
<form onSubmit={modalType === 'create' ? handleCreate : handleUpdate}>
<div className="px-6 py-4 space-y-4">
{modalError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 text-sm">{modalError}</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-[#00293d] mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-[#00293d] mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#00293d] mb-1">
Icon (emoji or icon name)
</label>
<input
type="text"
value={formData.icon}
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
placeholder="e.g. house, building, key"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#00293d] mb-1">
Sort Order
</label>
<input
type="number"
value={formData.sortOrder === 0 ? '' : formData.sortOrder}
onChange={(e) => setFormData({ ...formData, sortOrder: parseInt(e.target.value) || 0 })}
onFocus={(e) => e.target.select()}
placeholder="0"
min={0}
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isActive"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded border-[#e5e7eb] text-[#f5a623] focus:ring-0"
/>
<label htmlFor="isActive" className="ml-2 text-sm text-[#00293d] font-serif">
Active
</label>
</div>
</div>
<div className="px-6 py-4 border-t border-[#e5e7eb] flex justify-end space-x-3">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 border border-[#e5e7eb] text-[#00293d] rounded-xl hover:bg-[#f5f9f8] transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 bg-[#f5a623] text-white rounded-xl hover:bg-[#e09620] transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : modalType === 'create' ? 'Create' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{modalType === 'delete' && selectedType && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-[#e5e7eb]">
<h3 className="text-lg font-semibold text-[#00293d]">Delete Agent Type</h3>
</div>
<div className="px-6 py-4">
{modalError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 text-sm">{modalError}</p>
</div>
)}
<p className="text-[#666666] font-serif">
Are you sure you want to delete <span className="font-semibold">{selectedType.name}</span>?
{selectedType._count?.agents && selectedType._count.agents > 0 && (
<span className="block mt-2 text-red-600 text-sm">
Warning: This agent type has {selectedType._count.agents} agent(s) assigned to it.
</span>
)}
</p>
</div>
<div className="px-6 py-4 border-t border-[#e5e7eb] flex justify-end space-x-3">
<button
onClick={closeModal}
className="px-4 py-2 border border-[#e5e7eb] text-[#00293d] rounded-xl hover:bg-[#f5f9f8] transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={isSubmitting}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,340 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
auditLogService,
type AuditLogEntry,
type AuditLogQuery,
} from '@/services';
const ACTION_OPTIONS: { value: string; label: string; group: string }[] = [
{ value: '', label: 'All actions', group: '' },
// Auth
{ value: 'AUTH_SIGNUP', label: 'Auth: Signup', group: 'Auth' },
{ value: 'AUTH_LOGIN_SUCCESS', label: 'Auth: Login Success', group: 'Auth' },
{ value: 'AUTH_LOGIN_FAIL', label: 'Auth: Login Fail', group: 'Auth' },
{ value: 'AUTH_LOGOUT', label: 'Auth: Logout', group: 'Auth' },
{ value: 'AUTH_PASSWORD_RESET_REQUEST', label: 'Auth: Password Reset Requested', group: 'Auth' },
{ value: 'AUTH_PASSWORD_RESET_CONFIRM', label: 'Auth: Password Reset Confirmed', group: 'Auth' },
{ value: 'AUTH_PASSWORD_CHANGE', label: 'Auth: Password Change', group: 'Auth' },
{ value: 'AUTH_EMAIL_CHANGE', label: 'Auth: Email Change', group: 'Auth' },
{ value: 'AUTH_EMAIL_VERIFY', label: 'Auth: Email Verify', group: 'Auth' },
// User / role
{ value: 'USER_UPDATE', label: 'User: Update', group: 'User' },
{ value: 'USER_DELETE', label: 'User: Delete', group: 'User' },
{ value: 'USER_SUSPEND', label: 'User: Suspend', group: 'User' },
{ value: 'USER_UNSUSPEND', label: 'User: Unsuspend', group: 'User' },
{ value: 'USER_ROLE_CHANGE', label: 'User: Role Change', group: 'User' },
// Agent
{ value: 'AGENT_PROFILE_UPDATE', label: 'Agent: Profile Update', group: 'Agent' },
{ value: 'AGENT_VERIFICATION_SUBMIT', label: 'Agent: Verification Submit', group: 'Agent' },
{ value: 'AGENT_VERIFICATION_APPROVE', label: 'Agent: Verification Approve', group: 'Agent' },
{ value: 'AGENT_VERIFICATION_REJECT', label: 'Agent: Verification Reject', group: 'Agent' },
// Billing
{ value: 'SUBSCRIPTION_CREATE', label: 'Billing: Subscription Create', group: 'Billing' },
{ value: 'SUBSCRIPTION_CANCEL', label: 'Billing: Subscription Cancel', group: 'Billing' },
{ value: 'PAYMENT_SUCCESS', label: 'Billing: Payment Success', group: 'Billing' },
{ value: 'PAYMENT_FAILURE', label: 'Billing: Payment Failure', group: 'Billing' },
// Moderation
{ value: 'REPORT_STATUS_CHANGE', label: 'Moderation: Report Status Change', group: 'Moderation' },
{ value: 'CONTACT_MESSAGE_RESPOND', label: 'Moderation: Contact Message', group: 'Moderation' },
{ value: 'SUPPORT_TICKET_CLOSE', label: 'Moderation: Support Ticket Close', group: 'Moderation' },
// Schema
{ value: 'PROFILE_FIELD_UPDATE', label: 'Schema: Profile Field Update', group: 'Schema' },
{ value: 'PROFILE_SECTION_UPDATE', label: 'Schema: Profile Section Update', group: 'Schema' },
{ value: 'AGENT_TYPE_UPDATE', label: 'Schema: Agent Type Update', group: 'Schema' },
// Connections
{ value: 'CONNECTION_REQUEST_ACCEPT', label: 'Connection: Accept', group: 'Connection' },
{ value: 'CONNECTION_REQUEST_REJECT', label: 'Connection: Reject', group: 'Connection' },
{ value: 'CONNECTION_REQUEST_CANCEL', label: 'Connection: Cancel', group: 'Connection' },
// CMS
{ value: 'CMS_BLOG_PUBLISH', label: 'CMS: Publish', group: 'CMS' },
{ value: 'CMS_BLOG_UNPUBLISH', label: 'CMS: Unpublish', group: 'CMS' },
{ value: 'CMS_BLOG_DELETE', label: 'CMS: Delete', group: 'CMS' },
];
const RESOURCE_OPTIONS = [
'', 'User', 'AgentProfile', 'AgentSubscription', 'Payment', 'UserReport',
'ContactMessage', 'SupportChat', 'CmsContent', 'ProfileField', 'ProfileSection',
'AgentType', 'ConnectionRequest',
];
function formatActor(entry: AuditLogEntry): string {
if (!entry.actor) {
if (entry.actorRole === 'SYSTEM') return 'SYSTEM';
return entry.actorId ? `${entry.actorId.slice(0, 8)}` : 'anonymous';
}
const profile = entry.actor.agentProfile ?? entry.actor.userProfile;
const name = [profile?.firstName, profile?.lastName].filter(Boolean).join(' ');
return name || entry.actor.email;
}
function formatRelative(iso: string): string {
const d = new Date(iso);
const diff = Date.now() - d.getTime();
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}d ago`;
return d.toLocaleDateString();
}
export default function AuditLogPage() {
const [items, setItems] = useState<AuditLogEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const limit = 25;
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [expandedId, setExpandedId] = useState<string | null>(null);
// Filters
const [actionFilter, setActionFilter] = useState('');
const [resourceFilter, setResourceFilter] = useState('');
const [actorIdFilter, setActorIdFilter] = useState('');
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState('');
const fetchData = useCallback(async () => {
setLoading(true);
setError('');
try {
const query: AuditLogQuery = {
page,
limit,
action: actionFilter || undefined,
resourceType: resourceFilter || undefined,
actorId: actorIdFilter || undefined,
from: fromDate ? new Date(fromDate).toISOString() : undefined,
to: toDate ? new Date(toDate + 'T23:59:59').toISOString() : undefined,
};
const res = await auditLogService.list(query);
setItems(res.items);
setTotal(res.total);
} catch (err) {
console.error('Failed to fetch audit log:', err);
setError('Failed to load audit log');
} finally {
setLoading(false);
}
}, [page, actionFilter, resourceFilter, actorIdFilter, fromDate, toDate]);
useEffect(() => {
fetchData();
}, [fetchData]);
const totalPages = Math.max(1, Math.ceil(total / limit));
const resetFilters = () => {
setActionFilter('');
setResourceFilter('');
setActorIdFilter('');
setFromDate('');
setToDate('');
setPage(1);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Audit Log</h1>
<p className="text-sm text-gray-600 mt-1">
Who did what, when, and from where. Filter to narrow the trail.
</p>
</div>
<div className="bg-white rounded-lg shadow p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Action</label>
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
>
{ACTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Resource Type</label>
<select
value={resourceFilter}
onChange={(e) => { setResourceFilter(e.target.value); setPage(1); }}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
>
{RESOURCE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>{opt || 'All resources'}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Actor ID</label>
<input
type="text"
value={actorIdFilter}
onChange={(e) => setActorIdFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && setPage(1)}
placeholder="UUID"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white placeholder:text-gray-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">From</label>
<input
type="date"
value={fromDate}
onChange={(e) => { setFromDate(e.target.value); setPage(1); }}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">To</label>
<input
type="date"
value={toDate}
onChange={(e) => { setToDate(e.target.value); setPage(1); }}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
/>
</div>
<div className="lg:col-span-5 flex justify-end gap-2">
<button
onClick={resetFilters}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm"
>
Reset filters
</button>
</div>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
<div className="bg-white rounded-lg shadow overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : items.length === 0 ? (
<div className="py-12 text-center text-gray-500 text-sm">No audit entries match your filters.</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">When</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actor</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Resource</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{items.map((entry) => (
<>
<tr key={entry.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-900" title={new Date(entry.createdAt).toLocaleString()}>
{formatRelative(entry.createdAt)}
</td>
<td className="px-4 py-3 text-sm text-gray-900">
<div className="font-medium">{formatActor(entry)}</div>
<div className="text-xs text-gray-500">{entry.actorRole ?? 'SYSTEM'}</div>
</td>
<td className="px-4 py-3 text-sm">
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
{entry.action}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{entry.resourceType ? (
<>
<div>{entry.resourceType}</div>
{entry.resourceId && (
<div className="text-xs text-gray-500 font-mono" title={entry.resourceId}>
{entry.resourceId.slice(0, 8)}
</div>
)}
</>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 font-mono">
{entry.ipAddress ?? '—'}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
{expandedId === entry.id ? 'Hide' : 'Details'}
</button>
</td>
</tr>
{expandedId === entry.id && (
<tr key={`${entry.id}-detail`} className="bg-gray-50">
<td colSpan={6} className="px-4 py-3">
<div className="text-xs text-gray-700 space-y-1">
<div><span className="font-semibold">Full timestamp:</span> {new Date(entry.createdAt).toLocaleString()}</div>
<div><span className="font-semibold">Actor ID:</span> <span className="font-mono">{entry.actorId ?? '(none)'}</span></div>
{entry.actor?.email && (
<div><span className="font-semibold">Actor email:</span> {entry.actor.email}</div>
)}
{entry.userAgent && (
<div><span className="font-semibold">User Agent:</span> <span className="font-mono">{entry.userAgent}</span></div>
)}
{entry.metadata && (
<div>
<span className="font-semibold">Metadata:</span>
<pre className="mt-1 p-2 bg-white border border-gray-200 rounded text-xs font-mono overflow-x-auto">
{JSON.stringify(entry.metadata, null, 2)}
</pre>
</div>
)}
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
)}
{!loading && items.length > 0 && (
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
<div className="text-sm text-gray-600">
Showing {(page - 1) * limit + 1}{Math.min(page * limit, total)} of {total}
</div>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
Previous
</button>
<span className="px-3 py-1 text-sm text-gray-700">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1 border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
'use client';
import { useState, useEffect } from 'react';
import { contactService, type ContactMessage, type ContactCounts } from '@/services/contact.service';
export default function ContactMessagesPage() {
const [messages, setMessages] = useState<ContactMessage[]>([]);
const [counts, setCounts] = useState<ContactCounts | null>(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'all' | 'unread' | 'read'>('all');
const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null);
const fetchData = async () => {
try {
setLoading(true);
const isRead = filter === 'unread' ? false : filter === 'read' ? true : undefined;
const [msgs, cnts] = await Promise.all([
contactService.getAllMessages(isRead),
contactService.getCounts(),
]);
setMessages(msgs);
setCounts(cnts);
} catch (err) {
console.error('Failed to fetch contact messages:', err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchData(); }, [filter]);
const handleMarkAsRead = async (id: string) => {
try {
await contactService.markAsRead(id);
setMessages(prev => prev.map(m => m.id === id ? { ...m, isRead: true } : m));
setCounts(prev => prev ? { ...prev, unread: Math.max(0, prev.unread - 1) } : prev);
if (selectedMessage?.id === id) setSelectedMessage(prev => prev ? { ...prev, isRead: true } : prev);
} catch (err) {
console.error('Failed to mark as read:', err);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this message?')) return;
try {
await contactService.deleteMessage(id);
setMessages(prev => prev.filter(m => m.id !== id));
if (selectedMessage?.id === id) setSelectedMessage(null);
const cnts = await contactService.getCounts();
setCounts(cnts);
} catch (err) {
console.error('Failed to delete:', err);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
};
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Contact Messages</h1>
{/* Counts */}
{counts && (
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-sm text-gray-600">Total</p>
<p className="text-2xl font-bold text-gray-900">{counts.total}</p>
</div>
<div className="bg-yellow-50 rounded-lg p-4 border border-gray-200">
<p className="text-sm text-gray-600">Unread</p>
<p className="text-2xl font-bold text-gray-900">{counts.unread}</p>
</div>
</div>
)}
{/* Filter */}
<div className="mb-4 flex gap-2">
{(['all', 'unread', 'read'] as const).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === f ? 'bg-[#00293d] text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : messages.length === 0 ? (
<div className="text-center py-12 bg-white rounded-lg border border-gray-200">
<p className="text-gray-500">No contact messages found</p>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Message</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{messages.map(msg => (
<tr key={msg.id} className={`hover:bg-gray-50 ${!msg.isRead ? 'bg-yellow-50/50' : ''}`}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{!msg.isRead && <span className="w-2 h-2 bg-[#e58625] rounded-full flex-shrink-0" />}
<span className="text-sm font-medium text-gray-900">{msg.name}</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-500">{msg.email}</td>
<td className="px-4 py-3 text-sm text-gray-700 max-w-[250px] truncate">{msg.message}</td>
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">{formatDate(msg.createdAt)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button onClick={() => setSelectedMessage(msg)} className="text-sm text-blue-600 hover:text-blue-800">View</button>
{!msg.isRead && (
<button onClick={() => handleMarkAsRead(msg.id)} className="text-sm text-green-600 hover:text-green-800">Read</button>
)}
<button onClick={() => handleDelete(msg.id)} className="text-sm text-red-600 hover:text-red-800">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* View Modal */}
{selectedMessage && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Contact Message</h3>
<button onClick={() => setSelectedMessage(null)} className="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<div className="px-6 py-4 space-y-3">
<div>
<p className="text-sm font-medium text-gray-700">Name</p>
<p className="text-sm text-gray-900">{selectedMessage.name}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-700">Email</p>
<p className="text-sm text-gray-900">{selectedMessage.email}</p>
</div>
{selectedMessage.phone && (
<div>
<p className="text-sm font-medium text-gray-700">Phone</p>
<p className="text-sm text-gray-900">{selectedMessage.phone}</p>
</div>
)}
<div>
<p className="text-sm font-medium text-gray-700">Message</p>
<p className="text-sm text-gray-900 whitespace-pre-wrap">{selectedMessage.message}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-700">Received</p>
<p className="text-sm text-gray-900">{formatDate(selectedMessage.createdAt)}</p>
</div>
</div>
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-2">
{!selectedMessage.isRead && (
<button
onClick={() => handleMarkAsRead(selectedMessage.id)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
>
Mark as Read
</button>
)}
<button onClick={() => setSelectedMessage(null)} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm">
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -38,8 +38,8 @@ export default function DashboardLayout({
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-100"> <div className="min-h-screen flex items-center justify-center bg-[#f5f9f8]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#f5a623]"></div>
</div> </div>
); );
} }
@@ -49,35 +49,35 @@ export default function DashboardLayout({
} }
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-[#f5f9f8]">
{/* Sidebar */} {/* Sidebar */}
<Sidebar /> <Sidebar />
{/* Main Content */} {/* Main Content */}
<div className="ml-64"> <div className="ml-64">
{/* Top Header */} {/* Top Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-40"> <header className="bg-white border-b border-[#e5e7eb] sticky top-0 z-40">
<div className="px-8 py-3"> <div className="px-8 py-3">
<div className="flex justify-end items-center"> <div className="flex justify-end items-center">
{/* User Dropdown */} {/* User Dropdown */}
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => setIsDropdownOpen(!isDropdownOpen)} onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="flex items-center gap-3 py-2 px-3 rounded-xl hover:bg-gray-50 transition-all duration-200" className="flex items-center gap-3 py-2 px-3 rounded-xl hover:bg-[#f5f9f8] transition-all duration-200"
> >
<div className="w-9 h-9 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-sm"> <div className="w-9 h-9 bg-[#00293d] rounded-full flex items-center justify-center shadow-sm">
<span className="text-white font-semibold text-sm"> <span className="text-white font-semibold text-sm font-fractul">
{user?.firstName?.[0] || user?.email?.[0]?.toUpperCase() || 'A'} {user?.firstName?.[0] || user?.email?.[0]?.toUpperCase() || 'A'}
</span> </span>
</div> </div>
<div className="text-left hidden sm:block"> <div className="text-left hidden sm:block">
<p className="text-sm font-semibold text-gray-800"> <p className="text-sm font-semibold text-[#00293d] font-fractul">
{user?.firstName || 'Admin'} {user?.lastName || ''} {user?.firstName || 'Admin'} {user?.lastName || ''}
</p> </p>
<p className="text-xs text-gray-500">{user?.email}</p> <p className="text-xs text-[#666666] font-serif">{user?.email}</p>
</div> </div>
<svg <svg
className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`} className={`w-4 h-4 text-[#9ca3af] transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -88,19 +88,19 @@ export default function DashboardLayout({
{/* Dropdown Menu */} {/* Dropdown Menu */}
{isDropdownOpen && ( {isDropdownOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden z-50"> <div className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-lg border border-[#e5e7eb] overflow-hidden z-50">
<div className="px-4 py-4 bg-gradient-to-br from-gray-50 to-white border-b border-gray-100"> <div className="px-4 py-4 bg-[#f5f9f8] border-b border-[#e5e7eb]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-sm"> <div className="w-10 h-10 bg-[#00293d] rounded-full flex items-center justify-center shadow-sm">
<span className="text-white font-semibold"> <span className="text-white font-semibold font-fractul">
{user?.firstName?.[0] || user?.email?.[0]?.toUpperCase() || 'A'} {user?.firstName?.[0] || user?.email?.[0]?.toUpperCase() || 'A'}
</span> </span>
</div> </div>
<div> <div>
<p className="text-sm font-semibold text-gray-800"> <p className="text-sm font-semibold text-[#00293d] font-fractul">
{user?.firstName || 'Admin'} {user?.lastName || ''} {user?.firstName || 'Admin'} {user?.lastName || ''}
</p> </p>
<p className="text-xs text-gray-500 truncate">{user?.email}</p> <p className="text-xs text-[#666666] truncate font-serif">{user?.email}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { usersService, User } from '@/services'; import { usersService, User, uploadService } from '@/services';
import Link from 'next/link'; import Link from 'next/link';
export default function DashboardPage() { export default function DashboardPage() {
@@ -13,6 +13,7 @@ export default function DashboardPage() {
}); });
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [recentUsers, setRecentUsers] = useState<User[]>([]); const [recentUsers, setRecentUsers] = useState<User[]>([]);
const [avatarUrls, setAvatarUrls] = useState<Record<string, string>>({});
useEffect(() => { useEffect(() => {
fetchDashboardData(); fetchDashboardData();
@@ -29,7 +30,23 @@ export default function DashboardPage() {
agents: users.filter((u) => u.role === 'AGENT').length, agents: users.filter((u) => u.role === 'AGENT').length,
pendingUsers: users.filter((u) => u.status === 'PENDING_VERIFICATION').length, pendingUsers: users.filter((u) => u.status === 'PENDING_VERIFICATION').length,
}); });
setRecentUsers(users.slice(0, 5)); const recent = users.slice(0, 5);
setRecentUsers(recent);
// Fetch presigned URLs for avatars
const urls: Record<string, string> = {};
await Promise.all(
recent.map(async (user) => {
if (user.profile?.avatar && uploadService.isS3Key(user.profile.avatar)) {
try {
urls[user.id] = await uploadService.getPresignedDownloadUrl(user.profile.avatar);
} catch {
// Ignore errors for individual avatar fetches
}
}
})
);
setAvatarUrls(urls);
} catch (err) { } catch (err) {
console.error('Failed to fetch dashboard data:', err); console.error('Failed to fetch dashboard data:', err);
} finally { } finally {
@@ -40,7 +57,7 @@ export default function DashboardPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
</div> </div>
); );
} }
@@ -49,17 +66,17 @@ export default function DashboardPage() {
<div> <div>
{/* Page Title */} {/* Page Title */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1> <h1 className="text-2xl font-bold text-[#00293d] font-fractul">Dashboard</h1>
<p className="text-gray-500">Welcome to Re-Quest Admin Panel</p> <p className="text-[#666666] font-serif">Welcome to Re-Quest Admin Panel</p>
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"> <div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] p-6 card-hover">
<div className="flex items-center"> <div className="flex items-center">
<div className="p-3 bg-blue-100 rounded-lg"> <div className="p-3 bg-[#e8f0ee] rounded-xl">
<svg <svg
className="w-6 h-6 text-blue-600" className="w-6 h-6 text-[#5ba4a4]"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -73,14 +90,14 @@ export default function DashboardPage() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<p className="text-sm font-medium text-gray-500">Total Users</p> <p className="text-sm font-medium text-[#666666] font-serif">Total Users</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalUsers}</p> <p className="text-2xl font-bold text-[#00293d] font-fractul">{stats.totalUsers}</p>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] p-6 card-hover">
<div className="flex items-center"> <div className="flex items-center">
<div className="p-3 bg-green-100 rounded-lg"> <div className="p-3 bg-green-50 rounded-xl">
<svg <svg
className="w-6 h-6 text-green-600" className="w-6 h-6 text-green-600"
fill="none" fill="none"
@@ -96,16 +113,16 @@ export default function DashboardPage() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<p className="text-sm font-medium text-gray-500">Active</p> <p className="text-sm font-medium text-[#666666] font-serif">Active</p>
<p className="text-2xl font-bold text-gray-900">{stats.activeUsers}</p> <p className="text-2xl font-bold text-[#00293d] font-fractul">{stats.activeUsers}</p>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] p-6 card-hover">
<div className="flex items-center"> <div className="flex items-center">
<div className="p-3 bg-purple-100 rounded-lg"> <div className="p-3 bg-[#fff7ed] rounded-xl">
<svg <svg
className="w-6 h-6 text-purple-600" className="w-6 h-6 text-[#f5a623]"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -119,14 +136,14 @@ export default function DashboardPage() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<p className="text-sm font-medium text-gray-500">Agents</p> <p className="text-sm font-medium text-[#666666] font-serif">Agents</p>
<p className="text-2xl font-bold text-gray-900">{stats.agents}</p> <p className="text-2xl font-bold text-[#00293d] font-fractul">{stats.agents}</p>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] p-6 card-hover">
<div className="flex items-center"> <div className="flex items-center">
<div className="p-3 bg-yellow-100 rounded-lg"> <div className="p-3 bg-yellow-50 rounded-xl">
<svg <svg
className="w-6 h-6 text-yellow-600" className="w-6 h-6 text-yellow-600"
fill="none" fill="none"
@@ -142,21 +159,21 @@ export default function DashboardPage() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<p className="text-sm font-medium text-gray-500">Pending</p> <p className="text-sm font-medium text-[#666666] font-serif">Pending</p>
<p className="text-2xl font-bold text-gray-900">{stats.pendingUsers}</p> <p className="text-2xl font-bold text-[#00293d] font-fractul">{stats.pendingUsers}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Recent Users */} {/* Recent Users */}
<div className="bg-white rounded-lg shadow"> <div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb]">
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-4 border-b border-[#e5e7eb]">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-gray-900">Recent Users</h2> <h2 className="text-lg font-semibold text-[#00293d] font-fractul">Recent Users</h2>
<Link <Link
href="/dashboard/users" href="/dashboard/users"
className="text-blue-600 hover:text-blue-700 text-sm font-medium" className="text-[#f5a623] hover:text-[#e09620] text-sm font-medium font-serif transition-colors"
> >
View All View All
</Link> </Link>
@@ -166,7 +183,7 @@ export default function DashboardPage() {
{recentUsers.length === 0 ? ( {recentUsers.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<svg <svg
className="mx-auto h-12 w-12 text-gray-400" className="mx-auto h-12 w-12 text-[#9ca3af]"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -178,51 +195,51 @@ export default function DashboardPage() {
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/> />
</svg> </svg>
<p className="mt-2 text-gray-500">No users found</p> <p className="mt-2 text-[#666666] font-serif">No users found</p>
</div> </div>
) : ( ) : (
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-[#e5e7eb]">
<thead className="bg-gray-50"> <thead className="bg-[#f5f9f8]">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider font-serif">
User User
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider font-serif">
Role Role
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider font-serif">
Status Status
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider font-serif">
Joined Joined
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-[#e5e7eb]">
{recentUsers.map((user) => ( {recentUsers.map((user) => (
<tr key={user.id} className="hover:bg-gray-50"> <tr key={user.id} className="hover:bg-[#f5f9f8] transition-colors">
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10"> <div className="flex-shrink-0 h-10 w-10">
{user.profile?.avatar ? ( <div className="h-10 w-10 rounded-full bg-[#e8f0ee] flex items-center justify-center relative overflow-hidden">
<img <span className="text-[#00293d] font-medium text-sm font-fractul">
className="h-10 w-10 rounded-full object-cover" {user.profile?.firstName?.[0] || user.email[0].toUpperCase()}
src={user.profile.avatar} </span>
alt="" {avatarUrls[user.id] && (
/> <img
) : ( className="absolute inset-0 h-10 w-10 rounded-full object-cover"
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center"> src={avatarUrls[user.id]}
<span className="text-gray-500 font-medium text-sm"> alt=""
{user.profile?.firstName?.[0] || user.email[0].toUpperCase()} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
</span> />
</div> )}
)} </div>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-[#00293d] font-serif">
{user.profile?.firstName} {user.profile?.lastName} {user.profile?.firstName} {user.profile?.lastName}
</div> </div>
<div className="text-sm text-gray-500">{user.email}</div> <div className="text-sm text-[#666666]">{user.email}</div>
</div> </div>
</div> </div>
</td> </td>
@@ -232,8 +249,8 @@ export default function DashboardPage() {
user.role === 'ADMIN' user.role === 'ADMIN'
? 'bg-red-100 text-red-800' ? 'bg-red-100 text-red-800'
: user.role === 'AGENT' : user.role === 'AGENT'
? 'bg-purple-100 text-purple-800' ? 'bg-[#fff7ed] text-[#f5a623]'
: 'bg-blue-100 text-blue-800' : 'bg-[#e8f0ee] text-[#5ba4a4]'
}`} }`}
> >
{user.role} {user.role}
@@ -254,7 +271,7 @@ export default function DashboardPage() {
{user.status === 'PENDING_VERIFICATION' ? 'PENDING' : user.status} {user.status === 'PENDING_VERIFICATION' ? 'PENDING' : user.status}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-[#666666] font-serif">
{new Date(user.createdAt).toLocaleDateString()} {new Date(user.createdAt).toLocaleDateString()}
</td> </td>
</tr> </tr>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,579 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import SectionIcon from '@/components/SectionIcon';
import {
profileSectionsService,
ProfileSection,
CreateSectionDto,
UpdateSectionDto,
getErrorMessage,
} from '@/services';
type ModalType = 'create' | 'edit' | 'delete' | null;
export default function ProfileSectionsPage() {
const router = useRouter();
const [sections, setSections] = useState<ProfileSection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [showInactive, setShowInactive] = useState(true);
// Modal state
const [modalType, setModalType] = useState<ModalType>(null);
const [selectedSection, setSelectedSection] = useState<ProfileSection | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [modalError, setModalError] = useState('');
// Form state (slug is only editable in the edit modal)
const [formData, setFormData] = useState<CreateSectionDto & { slug?: string }>({
name: '',
slug: '',
description: '',
icon: '',
isActive: true,
isGlobal: false,
isRepeatable: false,
sortOrder: 0,
});
useEffect(() => {
fetchSections();
}, [showInactive]);
const fetchSections = async () => {
setIsLoading(true);
setError('');
try {
const data = await profileSectionsService.getAll(showInactive);
setSections(data);
} catch (err) {
const errorMessage = getErrorMessage(err);
setError(errorMessage);
if (errorMessage.includes('Unauthorized')) {
router.push('/login');
}
} finally {
setIsLoading(false);
}
};
const openCreateModal = () => {
setFormData({
name: '',
slug: '',
description: '',
icon: '',
isActive: true,
isGlobal: false,
isRepeatable: false,
sortOrder: 0,
});
setModalError('');
setModalType('create');
};
const openEditModal = (section: ProfileSection) => {
setSelectedSection(section);
setFormData({
name: section.name,
slug: section.slug,
description: section.description || '',
icon: section.icon || '',
isActive: section.isActive,
isGlobal: section.isGlobal,
isRepeatable: section.isRepeatable || false,
sortOrder: section.sortOrder,
});
setModalError('');
setModalType('edit');
};
const openDeleteModal = (section: ProfileSection) => {
setSelectedSection(section);
setModalError('');
setModalType('delete');
};
const closeModal = () => {
setModalType(null);
setSelectedSection(null);
setModalError('');
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setModalError('');
try {
// Backend auto-generates slug on create; don't send the form slug field
const { slug: _slug, ...createPayload } = formData;
void _slug;
await profileSectionsService.create(createPayload);
closeModal();
fetchSections();
} catch (err) {
setModalError(getErrorMessage(err));
} finally {
setIsSubmitting(false);
}
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedSection) return;
setIsSubmitting(true);
setModalError('');
try {
await profileSectionsService.update(selectedSection.id, formData as UpdateSectionDto);
closeModal();
fetchSections();
} catch (err) {
setModalError(getErrorMessage(err));
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async () => {
if (!selectedSection) return;
setIsSubmitting(true);
setModalError('');
try {
await profileSectionsService.delete(selectedSection.id);
closeModal();
fetchSections();
} catch (err) {
setModalError(getErrorMessage(err));
} finally {
setIsSubmitting(false);
}
};
const toggleStatus = async (section: ProfileSection) => {
try {
await profileSectionsService.update(section.id, { isActive: !section.isActive });
fetchSections();
} catch (err) {
setError(getErrorMessage(err));
}
};
return (
<div>
{/* Page Title */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">Profile Sections</h1>
<p className="text-[#666666] font-serif">Manage profile sections and their fields for agent types</p>
</div>
{/* Sections Table */}
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb]">
<div className="px-6 py-4 border-b border-[#e5e7eb]">
<div className="flex justify-between items-center">
<div className="flex items-center space-x-4">
<label className="flex items-center space-x-2 text-sm text-[#666666] font-serif">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded border-[#e5e7eb] text-[#f5a623] focus:ring-0"
/>
<span>Show Inactive</span>
</label>
</div>
<div className="flex space-x-3">
<button
onClick={fetchSections}
className="px-4 py-2 border border-[#e5e7eb] hover:bg-[#f5f9f8] text-[#00293d] text-sm font-medium rounded-lg transition-colors flex items-center"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
<button
onClick={openCreateModal}
className="px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors flex items-center"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Section
</button>
</div>
</div>
</div>
{error && (
<div className="px-6 py-4 bg-red-50 border-b border-red-200">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
<div className="overflow-x-auto">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
</div>
) : sections.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-[#9ca3af]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
<p className="mt-2 text-[#666666] font-serif">No profile sections found</p>
<button
onClick={openCreateModal}
className="mt-4 px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors"
>
Create First Section
</button>
</div>
) : (
<table className="min-w-full divide-y divide-[#e5e7eb]">
<thead className="bg-[#f5f9f8]">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Section
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Description
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Fields
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Agent Types
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Global
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-[#666666] uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-[#e5e7eb]">
{sections.map((section) => (
<tr key={section.id} className="hover:bg-[#f5f9f8]">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{section.icon && (
<span className="mr-3 text-[#666666]">
<SectionIcon name={section.icon} className="w-5 h-5" />
</span>
)}
<div>
<div className="text-sm font-medium text-[#00293d]">{section.name}</div>
<div className="text-xs text-[#666666]">{section.slug}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-[#666666] max-w-xs truncate">
{section.description || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Link
href={`/dashboard/profile-sections/${section.id}`}
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-[#e8f0ee] text-[#5ba4a4] hover:bg-[#d5e5e1]"
>
{section._count?.fields || 0} fields
<svg className="ml-1 w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-[#666666]">
{section.isGlobal ? (
<span className="text-[#5ba4a4] font-medium">All (Global)</span>
) : (
`${section._count?.agentTypeSections || 0} assigned`
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center space-x-2">
{section.isSystem && (
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-[#fff7ed] text-[#f5a623]">
System
</span>
)}
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
section.isGlobal
? 'bg-[#fff7ed] text-[#f5a623]'
: 'bg-[#e8f0ee] text-[#666666]'
}`}
>
{section.isGlobal ? 'Global' : 'Specific'}
</span>
{section.isRepeatable && (
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-orange-100 text-orange-800">
Repeatable
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => toggleStatus(section)}
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full cursor-pointer transition-colors ${
section.isActive
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}`}
>
{section.isActive ? 'Active' : 'Inactive'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link
href={`/dashboard/profile-sections/${section.id}`}
className="text-[#f5a623] hover:text-[#e09620] mr-3 inline-flex items-center gap-1"
title="Manage"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Manage
</Link>
<button
onClick={() => openEditModal(section)}
className="text-[#5ba4a4] hover:text-[#5ba4a4]/80 mr-3 inline-flex items-center gap-1"
title="Edit"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
{section.isSystem ? (
<span
className="text-[#9ca3af] cursor-not-allowed inline-flex items-center gap-1"
title="System sections cannot be deleted"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</span>
) : (
<button
onClick={() => openDeleteModal(section)}
className="text-red-600 hover:text-red-900 inline-flex items-center gap-1"
title="Delete"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Create/Edit Modal */}
{(modalType === 'create' || modalType === 'edit') && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-[#e5e7eb]">
<h3 className="text-lg font-semibold text-[#00293d] font-fractul">
{modalType === 'create' ? 'Create Profile Section' : 'Edit Profile Section'}
</h3>
</div>
<form onSubmit={modalType === 'create' ? handleCreate : handleUpdate}>
<div className="px-6 py-4 space-y-4">
{modalError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 text-sm">{modalError}</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-[#00293d] mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
placeholder="e.g., Location, Experience, Education"
required
/>
</div>
{modalType === 'edit' && (
<div>
<label className="block text-sm font-medium text-[#00293d] mb-1">
Slug <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.slug || ''}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666] font-mono text-sm"
placeholder="e.g., basic-information"
pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$"
title="Lowercase letters, digits, and hyphens only (no leading/trailing/consecutive hyphens)"
required
/>
<p className="mt-1 text-xs text-[#666666]">
Lowercase letters, digits, and hyphens only. Used in API keys and field references change with care.
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-[#00293d] mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
placeholder="Brief description of this section"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#00293d] mb-1">
Icon (emoji)
</label>
<input
type="text"
value={formData.icon}
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
placeholder="e.g., location_on"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#00293d] mb-1">
Sort Order
</label>
<input
type="number"
value={formData.sortOrder === 0 ? '' : formData.sortOrder}
onChange={(e) => setFormData({ ...formData, sortOrder: parseInt(e.target.value) || 0 })}
onFocus={(e) => e.target.select()}
placeholder="0"
min={0}
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
/>
</div>
<div className="flex items-center space-x-6">
<div className="flex items-center">
<input
type="checkbox"
id="isActive"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded border-[#e5e7eb] text-[#f5a623] focus:ring-0"
/>
<label htmlFor="isActive" className="ml-2 text-sm text-[#00293d] font-serif">
Active
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isGlobal"
checked={formData.isGlobal}
onChange={(e) => setFormData({ ...formData, isGlobal: e.target.checked })}
className="rounded border-[#e5e7eb] text-[#5ba4a4] focus:ring-0"
/>
<label htmlFor="isGlobal" className="ml-2 text-sm text-[#00293d] font-serif">
Global (all agent types)
</label>
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isRepeatable"
checked={formData.isRepeatable}
onChange={(e) => setFormData({ ...formData, isRepeatable: e.target.checked })}
className="rounded border-[#e5e7eb] text-[#f5a623] focus:ring-0"
/>
<label htmlFor="isRepeatable" className="ml-2 text-sm text-[#00293d] font-serif">
Allow Multiple Entries (e.g., certifications, education)
</label>
</div>
</div>
<div className="px-6 py-4 border-t border-[#e5e7eb] flex justify-end space-x-3">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 border border-[#e5e7eb] text-[#00293d] rounded-xl hover:bg-[#f5f9f8] transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 bg-[#f5a623] text-white rounded-xl hover:bg-[#e09620] transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : modalType === 'create' ? 'Create' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{modalType === 'delete' && selectedSection && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-[#e5e7eb]">
<h3 className="text-lg font-semibold text-[#00293d] font-fractul">Delete Profile Section</h3>
</div>
<div className="px-6 py-4">
{modalError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 text-sm">{modalError}</p>
</div>
)}
<p className="text-[#666666] font-serif">
Are you sure you want to delete <span className="font-semibold">{selectedSection.name}</span>?
</p>
{(selectedSection._count?.fields && selectedSection._count.fields > 0) && (
<p className="mt-2 text-amber-600 text-sm">
Warning: This section has {selectedSection._count.fields} field(s). They will also be deleted.
</p>
)}
</div>
<div className="px-6 py-4 border-t border-[#e5e7eb] flex justify-end space-x-3">
<button
onClick={closeModal}
className="px-4 py-2 border border-[#e5e7eb] text-[#00293d] rounded-xl hover:bg-[#f5f9f8] transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={isSubmitting}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,253 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { userReportsService, type UserReport, type ReportCounts } from '@/services/user-reports.service';
import { getErrorMessage } from '@/services';
function getUserName(user: UserReport['reporter']): string {
const profile = user.userProfile || user.agentProfile;
if (profile?.firstName || profile?.lastName) {
return [profile.firstName, profile.lastName].filter(Boolean).join(' ');
}
return user.email;
}
const STATUS_COLORS: Record<string, string> = {
PENDING: 'bg-yellow-100 text-yellow-800',
REVIEWED: 'bg-blue-100 text-blue-800',
RESOLVED: 'bg-green-100 text-green-800',
DISMISSED: 'bg-gray-100 text-gray-800',
};
export default function ReportsPage() {
const router = useRouter();
const [reports, setReports] = useState<UserReport[]>([]);
const [counts, setCounts] = useState<ReportCounts | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [selectedReport, setSelectedReport] = useState<UserReport | null>(null);
const [adminNotes, setAdminNotes] = useState('');
const [updatingId, setUpdatingId] = useState<string | null>(null);
const fetchData = async () => {
try {
setLoading(true);
setError('');
const [reportsList, reportCounts] = await Promise.all([
userReportsService.getAllReports(statusFilter || undefined),
userReportsService.getReportCounts(),
]);
setReports(reportsList);
setCounts(reportCounts);
} catch (err) {
const msg = getErrorMessage(err);
setError(msg);
if (msg.includes('Unauthorized')) router.push('/login');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [statusFilter]);
const handleUpdateStatus = async (reportId: string, status: string) => {
try {
setUpdatingId(reportId);
const updated = await userReportsService.updateReport(reportId, status, adminNotes || undefined);
setReports((prev) => prev.map((r) => (r.id === reportId ? updated : r)));
setSelectedReport(null);
setAdminNotes('');
// Refresh counts
const reportCounts = await userReportsService.getReportCounts();
setCounts(reportCounts);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setUpdatingId(null);
}
};
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">User Reports</h1>
{/* Status Counts */}
{counts && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
{[
{ label: 'Total', value: counts.total, color: 'bg-gray-50' },
{ label: 'Pending', value: counts.pending, color: 'bg-yellow-50' },
{ label: 'Reviewed', value: counts.reviewed, color: 'bg-blue-50' },
{ label: 'Resolved', value: counts.resolved, color: 'bg-green-50' },
{ label: 'Dismissed', value: counts.dismissed, color: 'bg-gray-50' },
].map((stat) => (
<div key={stat.label} className={`${stat.color} rounded-lg p-4 border border-gray-200`}>
<p className="text-sm text-gray-600">{stat.label}</p>
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
</div>
))}
</div>
)}
{/* Filter */}
<div className="mb-4 flex items-center gap-3">
<label className="text-sm font-medium text-gray-700">Filter by status:</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white"
>
<option value="">All</option>
<option value="PENDING">Pending</option>
<option value="REVIEWED">Reviewed</option>
<option value="RESOLVED">Resolved</option>
<option value="DISMISSED">Dismissed</option>
</select>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg mb-4">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : reports.length === 0 ? (
<div className="text-center py-12 bg-white rounded-lg border border-gray-200">
<p className="text-gray-500">No reports found</p>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reporter</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reported User</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reason</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{reports.map((report) => (
<tr key={report.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<p className="text-sm font-medium text-gray-900">{getUserName(report.reporter)}</p>
<p className="text-xs text-gray-500">{report.reporter.email}</p>
</td>
<td className="px-4 py-3">
<p className="text-sm font-medium text-gray-900">{getUserName(report.reportedUser)}</p>
<p className="text-xs text-gray-500">{report.reportedUser.email}</p>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-900">{report.reason}</p>
{report.description && (
<p className="text-xs text-gray-500 mt-1 truncate max-w-[200px]">{report.description}</p>
)}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${STATUS_COLORS[report.status]}`}>
{report.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{new Date(report.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<button
onClick={() => {
setSelectedReport(report);
setAdminNotes(report.adminNotes || '');
}}
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
Review
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Review Modal */}
{selectedReport && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Review Report</h3>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<p className="text-sm font-medium text-gray-700">Reporter</p>
<p className="text-sm text-gray-900">{getUserName(selectedReport.reporter)} ({selectedReport.reporter.email})</p>
</div>
<div>
<p className="text-sm font-medium text-gray-700">Reported User</p>
<p className="text-sm text-gray-900">{getUserName(selectedReport.reportedUser)} ({selectedReport.reportedUser.email})</p>
</div>
<div>
<p className="text-sm font-medium text-gray-700">Reason</p>
<p className="text-sm text-gray-900">{selectedReport.reason}</p>
</div>
{selectedReport.description && (
<div>
<p className="text-sm font-medium text-gray-700">Description</p>
<p className="text-sm text-gray-900">{selectedReport.description}</p>
</div>
)}
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Admin Notes</label>
<textarea
value={adminNotes}
onChange={(e) => setAdminNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white placeholder:text-gray-500"
placeholder="Add notes about this report..."
/>
</div>
</div>
<div className="px-6 py-4 border-t border-gray-200 flex flex-wrap gap-2 justify-end">
<button
onClick={() => { setSelectedReport(null); setAdminNotes(''); }}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm"
>
Cancel
</button>
<button
onClick={() => handleUpdateStatus(selectedReport.id, 'DISMISSED')}
disabled={updatingId === selectedReport.id}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm disabled:opacity-50"
>
Dismiss
</button>
<button
onClick={() => handleUpdateStatus(selectedReport.id, 'REVIEWED')}
disabled={updatingId === selectedReport.id}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm disabled:opacity-50"
>
Mark Reviewed
</button>
<button
onClick={() => handleUpdateStatus(selectedReport.id, 'RESOLVED')}
disabled={updatingId === selectedReport.id}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm disabled:opacity-50"
>
Resolve
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,164 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { adminStripeService, SubscriptionPlan, getErrorMessage } from '@/services';
export default function SubscriptionPlansPage() {
const router = useRouter();
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [syncingId, setSyncingId] = useState<string | null>(null);
const [syncMessage, setSyncMessage] = useState<{ planId: string; text: string; isError?: boolean } | null>(null);
useEffect(() => {
fetchPlans();
}, []);
const fetchPlans = async () => {
setLoading(true);
setError('');
try {
const data = await adminStripeService.getAdminPlans();
setPlans(data);
} catch (err) {
const msg = getErrorMessage(err);
setError(msg);
if (msg.includes('Unauthorized')) router.push('/login');
} finally {
setLoading(false);
}
};
const handleSync = async (plan: SubscriptionPlan) => {
setSyncingId(plan.id);
setSyncMessage(null);
try {
const updated = await adminStripeService.syncPlanFromStripe(plan.id);
setPlans((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
setSyncMessage({
planId: plan.id,
text: `Synced: ${formatCurrency(updated.amount / 100)} / ${updated.interval}`,
});
} catch (err) {
setSyncMessage({ planId: plan.id, text: getErrorMessage(err), isError: true });
} finally {
setSyncingId(null);
setTimeout(() => setSyncMessage(null), 5000);
}
};
const formatCurrency = (amount: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Subscription Plans</h1>
<p className="text-gray-600 mt-1">
Manage subscription plans. Use &quot;Sync from Stripe&quot; to pull the latest amount,
currency, interval, name, and description directly from Stripe for a given price.
</p>
</div>
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">All Plans ({plans.length})</h2>
</div>
{error && (
<div className="mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : plans.length === 0 ? (
<div className="text-center py-12 text-gray-500">No plans found</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Stripe Price ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Interval
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Active
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{plans.map((plan) => (
<tr key={plan.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{plan.name}</div>
{plan.description && (
<div className="text-sm text-gray-500">{plan.description}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<code className="text-xs text-gray-700 bg-gray-100 px-2 py-1 rounded">
{plan.stripePriceId}
</code>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatCurrency(plan.amount / 100)}
<span className="text-gray-500 ml-1">{plan.currency.toUpperCase()}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{plan.interval}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
plan.isActive ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{plan.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={() => handleSync(plan)}
disabled={syncingId === plan.id}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{syncingId === plan.id ? 'Syncing...' : 'Sync from Stripe'}
</button>
{syncMessage && syncMessage.planId === plan.id && (
<p
className={`mt-2 text-xs ${
syncMessage.isError ? 'text-red-600' : 'text-green-600'
}`}
>
{syncMessage.text}
</p>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,234 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
adminStripeService,
AdminSubscription,
RevenueStats,
getErrorMessage,
} from '@/services';
const STATUS_OPTIONS = ['', 'ACTIVE', 'PAST_DUE', 'CANCELED', 'INCOMPLETE', 'UNPAID'];
const statusBadge = (status: string) => {
const styles: Record<string, string> = {
ACTIVE: 'bg-green-100 text-green-800',
PAST_DUE: 'bg-yellow-100 text-yellow-800',
CANCELED: 'bg-red-100 text-red-800',
INCOMPLETE: 'bg-gray-100 text-gray-800',
UNPAID: 'bg-red-100 text-red-800',
TRIALING: 'bg-blue-100 text-blue-800',
};
return (
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
{status}
</span>
);
};
export default function SubscriptionsPage() {
const router = useRouter();
const [subscriptions, setSubscriptions] = useState<AdminSubscription[]>([]);
const [stats, setStats] = useState<RevenueStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
useEffect(() => {
fetchData();
}, [statusFilter, page]);
const fetchData = async () => {
setLoading(true);
setError('');
try {
const [subsResult, statsResult] = await Promise.all([
adminStripeService.getAllSubscriptions({
status: statusFilter || undefined,
page,
limit: 20,
}),
adminStripeService.getRevenueStats(),
]);
setSubscriptions(subsResult.data);
setTotalPages(subsResult.totalPages);
setTotal(subsResult.total);
setStats(statsResult);
} catch (err) {
const errorMessage = getErrorMessage(err);
setError(errorMessage);
if (errorMessage.includes('Unauthorized')) {
router.push('/login');
}
} finally {
setLoading(false);
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
const getAgentName = (sub: AdminSubscription) => {
const profile = sub.user?.agentProfile;
if (profile?.firstName || profile?.lastName) {
return `${profile.firstName || ''} ${profile.lastName || ''}`.trim();
}
return sub.user?.email || 'Unknown';
};
return (
<div>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Subscriptions</h1>
<p className="text-gray-600 mt-1">Manage agent subscriptions and view revenue</p>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">Active Subscriptions</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.activeSubscriptions}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">Total Revenue</p>
<p className="text-2xl font-bold text-green-600 mt-1">{formatCurrency(stats.totalRevenue)}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">This Month</p>
<p className="text-2xl font-bold text-blue-600 mt-1">{formatCurrency(stats.monthlyRevenue)}</p>
</div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg shadow mb-6">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
All Subscriptions ({total})
</h2>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-900 bg-white text-sm"
>
<option value="">All Statuses</option>
{STATUS_OPTIONS.filter(Boolean).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{error && (
<div className="mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : subscriptions.length === 0 ? (
<div className="text-center py-12 text-gray-500">
No subscriptions found
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Agent</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Period End</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{subscriptions.map((sub) => (
<tr key={sub.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">{getAgentName(sub)}</div>
<div className="text-sm text-gray-500">{sub.user?.email}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{sub.plan?.name || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{statusBadge(sub.status)}
{sub.cancelAtPeriodEnd && (
<span className="ml-2 text-xs text-orange-600">(Canceling)</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{sub.plan ? formatCurrency(sub.plan.amount / 100) : '-'}
{sub.plan && <span className="text-gray-500">/{sub.plan.interval}</span>}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(sub.currentPeriodEnd)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(sub.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<p className="text-sm text-gray-700">
Page {page} of {totalPages}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 hover:bg-gray-50"
>
Previous
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 hover:bg-gray-50"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,378 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { supportChatService, getErrorMessage } from '@/services';
import type { SupportChat, SupportMessage } from '@/services';
function getUserDisplayName(chat: SupportChat): string {
const profile = chat.user.userProfile || chat.user.agentProfile;
if (profile && (profile.firstName || profile.lastName)) {
return [profile.firstName, profile.lastName].filter(Boolean).join(' ');
}
return chat.user.email;
}
function formatTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function formatMessageTime(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export default function SupportChatPage() {
const [chats, setChats] = useState<SupportChat[]>([]);
const [selectedChat, setSelectedChat] = useState<SupportChat | null>(null);
const [messages, setMessages] = useState<SupportMessage[]>([]);
const [messageInput, setMessageInput] = useState('');
const [filter, setFilter] = useState<'OPEN' | 'CLOSED'>('OPEN');
const [loading, setLoading] = useState(true);
const [messagesLoading, setMessagesLoading] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
const fetchChats = useCallback(async () => {
try {
const data = await supportChatService.getAllChats(filter);
setChats(data);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
}, [filter]);
const fetchMessages = useCallback(async (chatId: string) => {
try {
const data = await supportChatService.getMessages(chatId);
setMessages(data.messages);
setTimeout(scrollToBottom, 100);
} catch (err) {
setError(getErrorMessage(err));
}
}, [scrollToBottom]);
// Fetch chats on mount and filter change
useEffect(() => {
setLoading(true);
setSelectedChat(null);
setMessages([]);
fetchChats();
}, [fetchChats]);
// Polling for real-time updates
useEffect(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
pollIntervalRef.current = setInterval(() => {
fetchChats();
if (selectedChat) {
fetchMessages(selectedChat.id);
}
}, 5000);
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, [fetchChats, fetchMessages, selectedChat]);
const handleSelectChat = async (chat: SupportChat) => {
setSelectedChat(chat);
setMessagesLoading(true);
setMessages([]);
try {
const data = await supportChatService.getMessages(chat.id);
setMessages(data.messages);
if (chat.adminUnreadCount > 0) {
await supportChatService.markAsRead(chat.id);
fetchChats();
}
setTimeout(scrollToBottom, 100);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setMessagesLoading(false);
}
};
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedChat || !messageInput.trim() || sending) return;
setSending(true);
try {
await supportChatService.sendMessage(selectedChat.id, messageInput.trim());
setMessageInput('');
await fetchMessages(selectedChat.id);
fetchChats();
} catch (err) {
setError(getErrorMessage(err));
} finally {
setSending(false);
}
};
const handleCloseChat = async (chatId: string) => {
try {
await supportChatService.closeChat(chatId);
if (selectedChat?.id === chatId) {
setSelectedChat(null);
setMessages([]);
}
fetchChats();
} catch (err) {
setError(getErrorMessage(err));
}
};
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Support Chat</h1>
<p className="text-gray-600 mt-1">Manage user support conversations</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 text-sm">{error}</p>
<button
onClick={() => setError('')}
className="text-red-500 text-xs underline mt-1"
>
Dismiss
</button>
</div>
)}
{/* Filter Tabs */}
<div className="mb-4 flex space-x-2">
<button
onClick={() => setFilter('OPEN')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
filter === 'OPEN'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'
}`}
>
Open
</button>
<button
onClick={() => setFilter('CLOSED')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
filter === 'CLOSED'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'
}`}
>
Closed
</button>
</div>
{/* Two-Panel Layout */}
<div className="bg-white rounded-lg shadow flex" style={{ height: 'calc(100vh - 240px)' }}>
{/* Left Panel - Chat List */}
<div className="w-80 border-r border-gray-200 flex flex-col">
<div className="px-4 py-3 border-b border-gray-200">
<h2 className="text-sm font-semibold text-gray-700">
Conversations ({chats.length})
</h2>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : chats.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
No {filter.toLowerCase()} conversations
</div>
) : (
chats.map((chat) => (
<button
key={chat.id}
onClick={() => handleSelectChat(chat)}
className={`w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 transition-colors ${
selectedChat?.id === chat.id ? 'bg-blue-50' : ''
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900 truncate">
{getUserDisplayName(chat)}
</span>
<div className="flex items-center space-x-2">
{chat.adminUnreadCount > 0 && (
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
{chat.adminUnreadCount}
</span>
)}
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${
chat.status === 'OPEN'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{chat.status}
</span>
</div>
</div>
{chat.lastMessageText && (
<p className="text-xs text-gray-500 mt-1 truncate">
{chat.lastMessageText}
</p>
)}
{chat.lastMessageAt && (
<p className="text-xs text-gray-400 mt-0.5">
{formatTime(chat.lastMessageAt)}
</p>
)}
</button>
))
)}
</div>
</div>
{/* Right Panel - Messages */}
<div className="flex-1 flex flex-col">
{selectedChat ? (
<>
{/* Chat Header */}
<div className="px-6 py-3 border-b border-gray-200 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-900">
{getUserDisplayName(selectedChat)}
</h3>
<p className="text-xs text-gray-500">{selectedChat.user.email}</p>
</div>
{selectedChat.status === 'OPEN' && (
<button
onClick={() => handleCloseChat(selectedChat.id)}
className="px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Close Chat
</button>
)}
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
{messagesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : messages.length === 0 ? (
<div className="text-center text-gray-500 text-sm py-12">
No messages yet
</div>
) : (
messages.map((message) => {
const isAdmin = message.senderRole === 'ADMIN';
return (
<div
key={message.id}
className={`flex ${isAdmin ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
isAdmin
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
<p
className={`text-xs mt-1 ${
isAdmin ? 'text-blue-200' : 'text-gray-400'
}`}
>
{formatMessageTime(message.createdAt)}
</p>
</div>
</div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
{selectedChat.status === 'OPEN' && (
<form
onSubmit={handleSendMessage}
className="px-6 py-3 border-t border-gray-200 flex items-center space-x-3"
>
<input
type="text"
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
placeholder="Type a message..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white placeholder:text-gray-500"
/>
<button
type="submit"
disabled={!messageInput.trim() || sending}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{sending ? (
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
'Send'
)}
</button>
</form>
)}
{selectedChat.status === 'CLOSED' && (
<div className="px-6 py-3 border-t border-gray-200 text-center">
<p className="text-sm text-gray-500">This conversation has been closed</p>
</div>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<svg
className="w-16 h-16 text-gray-300 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<p className="text-gray-500 text-sm">Select a conversation to view messages</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,839 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import {
usersService,
User,
getErrorMessage,
uploadService,
agentTypesService,
AgentType,
VerificationStatus,
VerificationDocument,
VerificationHistoryEntry,
AgentFieldValue,
} from '@/services';
export default function UserDetailPage() {
const router = useRouter();
const params = useParams();
const userId = params.id as string;
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
// Agent type editing
const [agentTypes, setAgentTypes] = useState<AgentType[]>([]);
const [selectedAgentTypeId, setSelectedAgentTypeId] = useState<string>('');
const [isUpdatingAgentType, setIsUpdatingAgentType] = useState(false);
const [updateSuccess, setUpdateSuccess] = useState('');
// Verification
const [verificationDocuments, setVerificationDocuments] = useState<VerificationDocument[]>([]);
const [isLoadingDocuments, setIsLoadingDocuments] = useState(false);
const [verificationNote, setVerificationNote] = useState('');
const [isUpdatingVerification, setIsUpdatingVerification] = useState(false);
const [verificationHistory, setVerificationHistory] = useState<VerificationHistoryEntry[]>([]);
const [agentFieldValues, setAgentFieldValues] = useState<AgentFieldValue[]>([]);
const [isTogglingStatus, setIsTogglingStatus] = useState(false);
// Is the agent subscription active (paid)?
const PAID_STATUSES = new Set(['ACTIVE', 'TRIALING', 'PAST_DUE']);
const isSubscriptionActive = (status?: string | null) =>
!!status && PAID_STATUSES.has(status.toUpperCase());
// Helper function to check if avatar is an S3 key
const isS3Key = (avatar: string | null | undefined): boolean => {
if (!avatar) return false;
return !avatar.startsWith('http') && !avatar.startsWith('/');
};
useEffect(() => {
fetchUser();
fetchAgentTypes();
}, [userId]);
// Fetch verification documents when user is loaded and is an agent
useEffect(() => {
if (user && user.role === 'AGENT') {
fetchVerificationDocuments();
fetchVerificationHistory();
if (user.agentProfile?.id) {
fetchAgentFieldValues(user.agentProfile.id);
}
}
}, [user?.id, user?.role]);
const handleToggleStatus = async () => {
if (!user) return;
const newStatus = user.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
const confirmed = window.confirm(
newStatus === 'INACTIVE'
? 'Deactivate this user? They will not be able to login and their profile will be hidden from search.'
: 'Reactivate this user? They will be able to login again.'
);
if (!confirmed) return;
setIsTogglingStatus(true);
setError('');
try {
const updated = await usersService.updateUserStatus(user.id, newStatus);
// Update local state immediately from API response so UI reflects the
// new status without a full refetch (which toggles isLoading and hides
// the content). Merge to preserve nested fields the PATCH may not return.
setUser((prev) => (prev ? { ...prev, ...updated, status: updated?.status ?? newStatus } : prev));
} catch (err) {
setError(getErrorMessage(err));
} finally {
setIsTogglingStatus(false);
}
};
const fetchUser = async () => {
setIsLoading(true);
setError('');
try {
const userData = await usersService.getUserById(userId);
setUser(userData);
// Set initial agent type
if (userData.agentProfile?.agentTypeId) {
setSelectedAgentTypeId(userData.agentProfile.agentTypeId);
}
// Fetch avatar URL if needed
const avatar = userData.profile?.avatar;
if (avatar) {
if (isS3Key(avatar)) {
try {
const presignedUrl = await uploadService.getPresignedDownloadUrl(avatar);
setAvatarUrl(presignedUrl);
} catch (err) {
console.error('Failed to get avatar URL:', err);
}
} else {
setAvatarUrl(avatar);
}
}
} catch (err) {
const errorMessage = getErrorMessage(err);
setError(errorMessage);
if (errorMessage.includes('Unauthorized')) {
router.push('/login');
}
} finally {
setIsLoading(false);
}
};
const fetchAgentTypes = async () => {
try {
const types = await agentTypesService.getAll();
setAgentTypes(types);
} catch (err) {
console.error('Failed to fetch agent types:', err);
}
};
const fetchVerificationDocuments = async () => {
if (!user || user.role !== 'AGENT') return;
setIsLoadingDocuments(true);
try {
const docs = await usersService.getVerificationDocuments(userId);
// Fetch presigned URLs for each document
const docsWithUrls = await Promise.all(
docs.map(async (doc) => {
try {
// The S3 key is stored in 'id' field from the upload service
// or in 'key' field if explicitly set. Fall back to id if key is missing.
const fileKey = doc.key || doc.id;
if (!fileKey || fileKey.startsWith('http')) {
// If no key or already a URL, return document as-is
return doc;
}
const url = await uploadService.getPresignedDownloadUrl(fileKey);
return { ...doc, url };
} catch {
return doc;
}
})
);
setVerificationDocuments(docsWithUrls);
} catch (err) {
console.error('Failed to fetch verification documents:', err);
} finally {
setIsLoadingDocuments(false);
}
};
const fetchVerificationHistory = async () => {
try {
const history = await usersService.getVerificationHistory(userId);
setVerificationHistory(history);
} catch {
// History may not exist yet
}
};
const fetchAgentFieldValues = async (agentProfileId: string) => {
try {
const values = await usersService.getAgentFieldValues(agentProfileId);
setAgentFieldValues(values);
} catch {
// Field values may not exist
}
};
const handleVerification = async (status: VerificationStatus) => {
if (!user) return;
setIsUpdatingVerification(true);
setError('');
setUpdateSuccess('');
try {
await usersService.updateVerification(user.id, {
status,
note: verificationNote || undefined,
});
setUpdateSuccess(
status === 'APPROVED'
? 'Agent verification approved successfully'
: 'Agent verification rejected'
);
setVerificationNote('');
// Refresh user data and history
await fetchUser();
await fetchVerificationHistory();
// Clear success message after 3 seconds
setTimeout(() => setUpdateSuccess(''), 3000);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setIsUpdatingVerification(false);
}
};
const getVerificationStatusBadge = (status: VerificationStatus | undefined) => {
switch (status) {
case 'APPROVED':
return (
<span className="px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800">
Approved
</span>
);
case 'REJECTED':
return (
<span className="px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800">
Rejected
</span>
);
case 'PENDING_REVIEW':
return (
<span className="px-3 py-1 text-sm font-semibold rounded-full bg-yellow-100 text-yellow-800">
Pending Review
</span>
);
default:
return (
<span className="px-3 py-1 text-sm font-semibold rounded-full bg-gray-100 text-gray-800">
Not Submitted
</span>
);
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const handleUpdateAgentType = async () => {
if (!selectedAgentTypeId || !user) return;
setIsUpdatingAgentType(true);
setError('');
setUpdateSuccess('');
try {
await usersService.updateAgentType(user.id, selectedAgentTypeId);
setUpdateSuccess('Agent type updated successfully');
// Refresh user data
await fetchUser();
// Clear success message after 3 seconds
setTimeout(() => setUpdateSuccess(''), 3000);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setIsUpdatingAgentType(false);
}
};
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
</div>
);
}
if (error && !user) {
return (
<div className="p-6">
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700">{error}</p>
</div>
<button
onClick={() => router.push('/dashboard/users')}
className="mt-4 px-4 py-2 text-[#f5a623] hover:text-[#e09620]"
>
Back to Users
</button>
</div>
);
}
if (!user) {
return (
<div className="p-6">
<p className="text-[#666666] font-serif">User not found</p>
<button
onClick={() => router.push('/dashboard/users')}
className="mt-4 px-4 py-2 text-[#f5a623] hover:text-[#e09620]"
>
Back to Users
</button>
</div>
);
}
return (
<div>
{/* Back Button */}
<button
onClick={() => router.push('/dashboard/users')}
className="mb-6 flex items-center text-[#666666] hover:text-[#00293d]"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Users
</button>
{/* Error/Success Messages */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{updateSuccess && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
<p className="text-green-700 text-sm">{updateSuccess}</p>
</div>
)}
{/* User Header */}
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] mb-6">
<div className="p-6">
<div className="flex items-center">
{/* Avatar */}
<div className="flex-shrink-0 h-20 w-20">
<div className="h-20 w-20 rounded-full bg-[#e8f0ee] flex items-center justify-center relative overflow-hidden">
<span className="text-[#666666] font-medium text-2xl">
{user.profile?.firstName?.[0] || user.email[0].toUpperCase()}
</span>
{avatarUrl && (
<img className="absolute inset-0 h-20 w-20 rounded-full object-cover" src={avatarUrl} alt="" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
</div>
</div>
{/* Name & Email */}
<div className="ml-6 flex-1">
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">
{user.profile?.firstName} {user.profile?.lastName}
</h1>
<p className="text-[#666666] font-serif">{user.email}</p>
</div>
{/* Role & Status Badges */}
<div className="flex items-center space-x-3">
<span
className={`px-3 py-1 text-sm font-semibold rounded-full ${
user.role === 'ADMIN'
? 'bg-red-100 text-red-800'
: user.role === 'AGENT'
? 'bg-[#fff7ed] text-[#f5a623]'
: 'bg-[#e8f0ee] text-[#5ba4a4]'
}`}
>
{user.role}
</span>
<span
className={`px-3 py-1 text-sm font-semibold rounded-full ${
user.status === 'ACTIVE'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{user.status}
</span>
<button
onClick={handleToggleStatus}
disabled={isTogglingStatus}
className={`px-4 py-1.5 text-sm font-semibold rounded-lg transition-colors disabled:opacity-50 shadow-sm border ${
user.status === 'ACTIVE'
? 'bg-red-600 text-white hover:bg-red-700 border-red-600'
: 'bg-green-600 text-white hover:bg-green-700 border-green-600'
}`}
>
{isTogglingStatus ? 'Updating...' : user.status === 'ACTIVE' ? 'Deactivate User' : 'Activate User'}
</button>
</div>
</div>
</div>
</div>
{/* Basic Information */}
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] mb-6">
<div className="px-6 py-4 border-b border-[#e5e7eb]">
<h2 className="text-lg font-semibold text-[#00293d] font-fractul">Basic Information</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<p className="text-sm text-[#666666]">Provider</p>
<p className="text-sm font-medium text-[#00293d]">
{user.authProvider === 'LOCAL' ? 'Email' : user.authProvider}
</p>
</div>
<div>
<p className="text-sm text-[#666666]">Email Verified</p>
<div className="flex items-center">
{user.emailVerified ? (
<>
<svg className="w-5 h-5 text-green-500 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm font-medium text-green-600">Verified</span>
</>
) : (
<>
<svg className="w-5 h-5 text-[#9ca3af] mr-1" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm font-medium text-[#666666]">Not Verified</span>
</>
)}
</div>
</div>
<div>
<p className="text-sm text-[#666666]">Joined</p>
<p className="text-sm font-medium text-[#00293d]">{formatDate(user.createdAt)}</p>
</div>
<div>
<p className="text-sm text-[#666666]">Last Login</p>
<p className="text-sm font-medium text-[#00293d]">
{user.lastLoginAt ? formatDate(user.lastLoginAt) : 'Never'}
</p>
</div>
</div>
</div>
</div>
{/* Dynamic Profile Data (only for AGENT role) */}
{user.role === 'AGENT' && (
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] mb-6">
<div className="px-6 py-4 border-b border-[#e5e7eb]">
<h2 className="text-lg font-semibold text-[#00293d] font-fractul">Profile Details</h2>
</div>
<div className="p-6 space-y-5">
{/* Agent Type */}
{user.agentProfile && (
<div>
<h3 className="text-xs font-semibold text-[#00293d] uppercase tracking-wide mb-3 pb-2 border-b border-[#e5e7eb]">Agent Type</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<p className="text-xs text-[#666666] mb-0.5">Type</p>
<p className="text-sm font-medium text-[#00293d]">
{user.agentProfile.agentType?.name || 'Not assigned'}
</p>
</div>
</div>
</div>
)}
{Object.entries(
agentFieldValues
.filter((fv) => fv.sectionSlug !== 'upload-documents')
.reduce((acc, fv) => {
if (!acc[fv.sectionName]) acc[fv.sectionName] = [];
acc[fv.sectionName].push(fv);
return acc;
}, {} as Record<string, AgentFieldValue[]>)
).map(([sectionName, fields]) => (
<div key={sectionName}>
<h3 className="text-xs font-semibold text-[#00293d] uppercase tracking-wide mb-3 pb-2 border-b border-[#e5e7eb]">{sectionName}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{fields.map((fv) => (
<div key={fv.fieldSlug}>
<p className="text-xs text-[#666666] mb-0.5">{fv.fieldName}</p>
<p className="text-sm font-medium text-[#00293d]">
{fv.value === null || fv.value === undefined ? <span className="text-gray-400">-</span> :
Array.isArray(fv.value) ? fv.value.map((v: any) => typeof v === 'object' ? (v.label || v.name || JSON.stringify(v)) : String(v)).join(', ') :
typeof fv.value === 'object' ? JSON.stringify(fv.value) :
String(fv.value)}
</p>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Verification Section (only for AGENT role) */}
{user.role === 'AGENT' && user.agentProfile && (
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] mb-6">
<div className="px-6 py-4 border-b border-[#e5e7eb]">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-[#00293d] font-fractul">Verification Status</h2>
{getVerificationStatusBadge(user.agentProfile.verificationStatus as VerificationStatus)}
</div>
</div>
<div className="p-6">
{/* Payment / subscription status */}
<div className="mb-4 flex items-center justify-between p-3 rounded-lg border"
style={{
backgroundColor: isSubscriptionActive(user.agentProfile.subscriptionStatus) ? '#f0fdf4' : '#fef2f2',
borderColor: isSubscriptionActive(user.agentProfile.subscriptionStatus) ? '#bbf7d0' : '#fecaca',
}}>
<div>
<p className="text-sm font-medium text-gray-900">Payment Status</p>
<p className="text-xs text-gray-600 mt-0.5">
{user.agentProfile.subscriptionStatus || 'No subscription'}
</p>
</div>
{isSubscriptionActive(user.agentProfile.subscriptionStatus) ? (
<span className="px-3 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
Paid
</span>
) : (
<span className="px-3 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
Not Paid
</span>
)}
</div>
{/* Verification Info */}
{user.agentProfile.isVerified && user.agentProfile.verifiedAt && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
<p className="text-green-700 text-sm">
Verified on {formatDate(user.agentProfile.verifiedAt)}
</p>
</div>
)}
{user.agentProfile.verificationStatus === 'REJECTED' && user.agentProfile.verificationNote && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 text-sm font-medium">Rejection Note:</p>
<p className="text-red-600 text-sm mt-1">{user.agentProfile.verificationNote}</p>
</div>
)}
{/* Uploaded Documents */}
<div className="mb-6">
<h3 className="text-sm font-medium text-[#00293d] mb-3">Uploaded Documents</h3>
{isLoadingDocuments ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#f5a623]"></div>
</div>
) : verificationDocuments.length > 0 ? (
<div className="space-y-2">
{verificationDocuments.map((doc, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-[#f5f9f8] border border-[#e5e7eb] rounded-xl"
>
<div className="flex items-center">
<svg
className="w-8 h-8 text-[#9ca3af] mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<div>
<p className="text-sm font-medium text-[#00293d]">{doc.name}</p>
<p className="text-xs text-[#666666]">{formatFileSize(doc.size)}</p>
</div>
</div>
{doc.url && (
<a
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 text-sm text-[#f5a623] hover:text-[#e09620] hover:bg-[#fff7ed] rounded transition-colors"
>
Download
</a>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-[#666666] py-4 text-center">
No documents uploaded yet
</p>
)}
</div>
{/* Admin Actions */}
{user.agentProfile.verificationStatus !== 'APPROVED' && (
<div className="border-t border-[#e5e7eb] pt-6">
<h3 className="text-sm font-medium text-[#00293d] mb-3">Admin Actions</h3>
{/* Note Input */}
<div className="mb-4">
<label className="block text-sm text-[#666666] mb-1">
Note (optional, required for rejection)
</label>
<textarea
value={verificationNote}
onChange={(e) => setVerificationNote(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
placeholder="Enter a note for the agent (e.g., rejection reason)"
/>
</div>
{/* Action Buttons */}
<div className="flex space-x-3">
<button
onClick={() => handleVerification('APPROVED')}
disabled={isUpdatingVerification}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUpdatingVerification ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Processing...
</span>
) : (
<>
<svg
className="w-4 h-4 inline mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Approve Verification
</>
)}
</button>
<button
onClick={() => handleVerification('REJECTED')}
disabled={isUpdatingVerification || !verificationNote.trim()}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUpdatingVerification ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Processing...
</span>
) : (
<>
<svg
className="w-4 h-4 inline mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Reject Verification
</>
)}
</button>
</div>
{!verificationNote.trim() && (
<p className="mt-2 text-xs text-[#666666]">
* A note is required to reject verification
</p>
)}
</div>
)}
{/* Already Approved Message */}
{user.agentProfile.verificationStatus === 'APPROVED' && (
<div className="border-t border-[#e5e7eb] pt-6">
<div className="flex items-center text-green-600">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="font-medium">This agent is verified</span>
</div>
</div>
)}
{/* Verification History */}
{verificationHistory.length > 0 && (
<div className="border-t border-[#e5e7eb] pt-6">
<h3 className="text-sm font-medium text-[#00293d] mb-3">Verification History</h3>
<div className="space-y-3">
{verificationHistory.map((entry) => (
<div key={entry.id} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${
entry.status === 'APPROVED' ? 'bg-green-500' :
entry.status === 'REJECTED' ? 'bg-red-500' :
entry.status === 'PENDING_REVIEW' ? 'bg-yellow-500' : 'bg-gray-400'
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${
entry.status === 'APPROVED' ? 'bg-green-100 text-green-800' :
entry.status === 'REJECTED' ? 'bg-red-100 text-red-800' :
entry.status === 'PENDING_REVIEW' ? 'bg-yellow-100 text-yellow-800' : 'bg-gray-100 text-gray-800'
}`}>
{entry.status.replace('_', ' ')}
</span>
<span className="text-xs text-gray-500">
{new Date(entry.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
</span>
</div>
{entry.note && (
<p className="text-sm text-gray-700 mt-1">{entry.note}</p>
)}
{entry.admin && (
<p className="text-xs text-gray-500 mt-1">By: {entry.admin.email}</p>
)}
{entry.submittedData && (
<details className="mt-2">
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">View Submitted Data</summary>
<div className="mt-2 p-3 bg-white rounded border border-gray-200 text-xs space-y-1">
{entry.submittedData.firstName && (
<p><span className="font-medium text-gray-600">Name:</span> {entry.submittedData.firstName} {entry.submittedData.lastName}</p>
)}
{entry.submittedData.email && (
<p><span className="font-medium text-gray-600">Email:</span> {entry.submittedData.email}</p>
)}
{entry.submittedData.phone && (
<p><span className="font-medium text-gray-600">Phone:</span> {entry.submittedData.phone}</p>
)}
{entry.submittedData.agentType && (
<p><span className="font-medium text-gray-600">Type:</span> {entry.submittedData.agentType}</p>
)}
{entry.submittedData.sections && Object.entries(entry.submittedData.sections).map(([sectionSlug, sectionData]: [string, any]) => (
<div key={sectionSlug} className="mt-2">
<p className="font-medium text-gray-700">{sectionData._name || sectionSlug}</p>
{Object.entries(sectionData).filter(([k]) => k !== '_name').map(([fieldSlug, fieldData]: [string, any]) => (
<p key={fieldSlug} className="ml-2 text-gray-600">
<span className="font-medium">{fieldData.name || fieldSlug}:</span>{' '}
{typeof fieldData.value === 'object' ? JSON.stringify(fieldData.value) : String(fieldData.value ?? '-')}
</p>
))}
</div>
))}
<p className="text-gray-400 mt-1">Captured: {new Date(entry.submittedData.capturedAt).toLocaleString()}</p>
</div>
</details>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { usersService, User, getErrorMessage } from '@/services'; import { usersService, User, getErrorMessage, uploadService } from '@/services';
type RoleFilter = 'ALL' | 'ADMIN' | 'USER' | 'AGENT'; type RoleFilter = 'ALL' | 'ADMIN' | 'USER' | 'AGENT';
@@ -21,7 +21,39 @@ export default function UsersPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalUsers, setTotalUsers] = useState(0); const [totalUsers, setTotalUsers] = useState(0);
const [activeFilter, setActiveFilter] = useState<RoleFilter>('ALL'); const [activeFilter, setActiveFilter] = useState<RoleFilter>('ALL');
const limit = 10; const [avatarUrls, setAvatarUrls] = useState<Record<string, string>>({});
const limit = 50;
// Helper function to check if avatar is an S3 key
const isS3Key = (avatar: string | null | undefined): boolean => {
if (!avatar) return false;
return !avatar.startsWith('http') && !avatar.startsWith('/');
};
// Fetch presigned URLs for user avatars
const fetchAvatarUrls = async (userList: User[]) => {
const urls: Record<string, string> = {};
await Promise.all(
userList.map(async (user) => {
const avatar = user.profile?.avatar;
if (avatar) {
if (isS3Key(avatar)) {
try {
const presignedUrl = await uploadService.getPresignedDownloadUrl(avatar);
urls[user.id] = presignedUrl;
} catch (err) {
console.error(`Failed to get avatar URL for user ${user.id}:`, err);
}
} else {
urls[user.id] = avatar;
}
}
})
);
setAvatarUrls(urls);
};
useEffect(() => { useEffect(() => {
fetchUsers(); fetchUsers();
@@ -36,8 +68,12 @@ export default function UsersPage() {
filters.role = activeFilter; filters.role = activeFilter;
} }
const response = await usersService.getUsers(filters); const response = await usersService.getUsers(filters);
setUsers(response.users || []); const userList = response.users || [];
setUsers(userList);
setTotalUsers(response.total || 0); setTotalUsers(response.total || 0);
// Fetch presigned URLs for avatars
await fetchAvatarUrls(userList);
} catch (err) { } catch (err) {
const errorMessage = getErrorMessage(err); const errorMessage = getErrorMessage(err);
setError(errorMessage); setError(errorMessage);
@@ -60,24 +96,24 @@ export default function UsersPage() {
<div> <div>
{/* Page Title */} {/* Page Title */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Users</h1> <h1 className="text-2xl font-bold text-[#00293d] font-fractul">Users</h1>
<p className="text-gray-500">Manage all registered users</p> <p className="text-[#666666] font-serif">Manage all registered users</p>
</div> </div>
{/* Users Table */} {/* Users Table */}
<div className="bg-white rounded-lg shadow"> <div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb]">
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-4 border-b border-[#e5e7eb]">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
{/* Filter Tabs */} {/* Filter Tabs */}
<div className="flex space-x-1 bg-gray-100 rounded-lg p-1"> <div className="flex space-x-1 bg-[#f5f9f8] rounded-lg p-1">
{roleFilters.map((filter) => ( {roleFilters.map((filter) => (
<button <button
key={filter.value} key={filter.value}
onClick={() => handleFilterChange(filter.value)} onClick={() => handleFilterChange(filter.value)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${ className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeFilter === filter.value activeFilter === filter.value
? 'bg-white text-gray-900 shadow-sm' ? 'bg-white text-[#00293d] shadow-sm'
: 'text-gray-600 hover:text-gray-900' : 'text-[#666666] hover:text-[#00293d]'
}`} }`}
> >
{filter.label} {filter.label}
@@ -86,7 +122,7 @@ export default function UsersPage() {
</div> </div>
<button <button
onClick={fetchUsers} onClick={fetchUsers}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors flex items-center" className="px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors flex items-center"
> >
<svg <svg
className="w-4 h-4 mr-2" className="w-4 h-4 mr-2"
@@ -115,12 +151,12 @@ export default function UsersPage() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
</div> </div>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<svg <svg
className="mx-auto h-12 w-12 text-gray-400" className="mx-auto h-12 w-12 text-[#9ca3af]"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -132,60 +168,64 @@ export default function UsersPage() {
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/> />
</svg> </svg>
<p className="mt-2 text-gray-500">No users found</p> <p className="mt-2 text-[#666666]">No users found</p>
</div> </div>
) : ( ) : (
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-[#e5e7eb]">
<thead className="bg-gray-50"> <thead className="bg-[#f5f9f8]">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
User User
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Role Role
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Status Status
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Provider Provider
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Verified Verified
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Joined Joined
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
Last Login Last Login
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-[#e5e7eb]">
{users.map((user) => ( {users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50"> <tr
key={user.id}
className="hover:bg-[#f5f9f8] cursor-pointer"
onClick={() => router.push(`/dashboard/users/${user.id}`)}
>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10"> <div className="flex-shrink-0 h-10 w-10">
{user.profile?.avatar ? ( <div className="h-10 w-10 rounded-full bg-[#e8f0ee] flex items-center justify-center relative overflow-hidden">
<img <span className="text-[#666666] font-medium text-sm">
className="h-10 w-10 rounded-full object-cover" {user.profile?.firstName?.[0] || user.email[0].toUpperCase()}
src={user.profile.avatar} </span>
alt="" {avatarUrls[user.id] && (
/> <img
) : ( className="absolute inset-0 h-10 w-10 rounded-full object-cover"
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center"> src={avatarUrls[user.id]}
<span className="text-gray-500 font-medium text-sm"> alt=""
{user.profile?.firstName?.[0] || user.email[0].toUpperCase()} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
</span> />
</div> )}
)} </div>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-[#00293d]">
{user.profile?.firstName} {user.profile?.lastName} {user.profile?.firstName} {user.profile?.lastName}
</div> </div>
<div className="text-sm text-gray-500">{user.email}</div> <div className="text-sm text-[#666666]">{user.email}</div>
</div> </div>
</div> </div>
</td> </td>
@@ -195,8 +235,8 @@ export default function UsersPage() {
user.role === 'ADMIN' user.role === 'ADMIN'
? 'bg-red-100 text-red-800' ? 'bg-red-100 text-red-800'
: user.role === 'AGENT' : user.role === 'AGENT'
? 'bg-purple-100 text-purple-800' ? 'bg-[#fff7ed] text-[#f5a623]'
: 'bg-blue-100 text-blue-800' : 'bg-[#e8f0ee] text-[#5ba4a4]'
}`} }`}
> >
{user.role} {user.role}
@@ -217,7 +257,7 @@ export default function UsersPage() {
{user.status === 'PENDING_VERIFICATION' ? 'PENDING' : user.status} {user.status === 'PENDING_VERIFICATION' ? 'PENDING' : user.status}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-[#666666]">
{user.authProvider === 'LOCAL' ? 'Email' : user.authProvider} {user.authProvider === 'LOCAL' ? 'Email' : user.authProvider}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
@@ -235,7 +275,7 @@ export default function UsersPage() {
</svg> </svg>
) : ( ) : (
<svg <svg
className="w-5 h-5 text-gray-400" className="w-5 h-5 text-[#9ca3af]"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
> >
@@ -247,10 +287,10 @@ export default function UsersPage() {
</svg> </svg>
)} )}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-[#666666]">
{new Date(user.createdAt).toLocaleDateString()} {new Date(user.createdAt).toLocaleDateString()}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-[#666666]">
{user.lastLoginAt {user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleDateString() ? new Date(user.lastLoginAt).toLocaleDateString()
: 'Never'} : 'Never'}
@@ -264,8 +304,8 @@ export default function UsersPage() {
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between"> <div className="px-6 py-4 border-t border-[#e5e7eb] flex items-center justify-between">
<p className="text-sm text-gray-500"> <p className="text-sm text-[#666666]">
Showing {(currentPage - 1) * limit + 1} to{' '} Showing {(currentPage - 1) * limit + 1} to{' '}
{Math.min(currentPage * limit, totalUsers)} of {totalUsers} users {Math.min(currentPage * limit, totalUsers)} of {totalUsers} users
</p> </p>
@@ -273,14 +313,14 @@ export default function UsersPage() {
<button <button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
className="px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" className="px-3 py-1 border border-[#e5e7eb] rounded-md text-sm font-medium text-[#00293d] hover:bg-[#f5f9f8] disabled:opacity-50 disabled:cursor-not-allowed"
> >
Previous Previous
</button> </button>
<button <button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" className="px-3 py-1 border border-[#e5e7eb] rounded-md text-sm font-medium text-[#00293d] hover:bg-[#f5f9f8] disabled:opacity-50 disabled:cursor-not-allowed"
> >
Next Next
</button> </button>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -1,26 +1,70 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--background: #ffffff; /* RE-Quest Admin Design Tokens (matching web app) */
--foreground: #171717; --color-primary-dark: #00293d;
--color-primary-light: #c4d9d4;
--color-accent-orange: #f5a623;
--color-accent-orange-hover: #e09620;
--color-accent-teal: #5ba4a4;
--color-background: #ffffff;
--color-background-alt: #f5f9f8;
--color-foreground: #171717;
--color-text-gray: #666666;
--color-text-muted: #9ca3af;
--color-border: #e5e7eb;
/* Spacing */
--spacing-section: 6rem;
--spacing-section-sm: 4rem;
/* Border Radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--radius-xl: 1.5rem;
--radius-2xl: 2rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: #ffffff;
--color-foreground: var(--foreground); --color-foreground: #171717;
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
} --font-serif: var(--font-source-serif-4);
--font-fractul: var(--font-fractul);
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
} }
body { body {
background: var(--background); background: var(--color-background);
color: var(--foreground); color: var(--color-foreground);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-source-serif-4, Arial, Helvetica, sans-serif);
}
/* Selection styling to match web */
::selection {
background-color: var(--color-primary-light);
color: var(--color-primary-dark);
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Card hover effects */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
} }

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css"; import "./globals.css";
import { AuthProvider } from "@/context/AuthContext"; import { AuthProvider } from "@/context/AuthContext";
@@ -13,6 +14,110 @@ const geistMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
}); });
// Local Fractul font (matching web app)
const fractul = localFont({
src: [
{
path: "../fonts/fractul/Fractul-Regular.ttf",
weight: "400",
style: "normal",
},
{
path: "../fonts/fractul/Fractul-Italic.ttf",
weight: "400",
style: "italic",
},
{
path: "../fonts/fractul/Fractul-Medium.ttf",
weight: "500",
style: "normal",
},
{
path: "../fonts/fractul/Fractul-MediumItalic.ttf",
weight: "500",
style: "italic",
},
{
path: "../fonts/fractul/Fractul-SemiBold.ttf",
weight: "600",
style: "normal",
},
{
path: "../fonts/fractul/Fractul-SemiBoldItalic.ttf",
weight: "600",
style: "italic",
},
{
path: "../fonts/fractul/Fractul-Bold.ttf",
weight: "700",
style: "normal",
},
{
path: "../fonts/fractul/Fractul-BoldItalic.ttf",
weight: "700",
style: "italic",
},
{
path: "../fonts/fractul/Fractul-ExtraBold.ttf",
weight: "800",
style: "normal",
},
{
path: "../fonts/fractul/Fractul-ExtraBoldItalic.ttf",
weight: "800",
style: "italic",
},
],
variable: "--font-fractul",
});
// Local Source Serif 4 font (matching web app)
const sourceSerif4 = localFont({
src: [
{
path: "../fonts/Source_Serif_4/static/SourceSerif4-Regular.ttf",
weight: "400",
style: "normal",
},
{
path: "../fonts/Source_Serif_4/static/SourceSerif4-Italic.ttf",
weight: "400",
style: "italic",
},
{
path: "../fonts/Source_Serif_4/static/SourceSerif4-Medium.ttf",
weight: "500",
style: "normal",
},
{
path: "../fonts/Source_Serif_4/static/SourceSerif4-MediumItalic.ttf",
weight: "500",
style: "italic",
},
{
path: "../fonts/Source_Serif_4/static/SourceSerif4-SemiBold.ttf",
weight: "600",
style: "normal",
},
{
path: "../fonts/Source_Serif_4/static/SourceSerif4-SemiBoldItalic.ttf",
weight: "600",
style: "italic",
},
{
path: "../fonts/Source_Serif_4/static/SourceSerif4-Bold.ttf",
weight: "700",
style: "normal",
},
{
path: "../fonts/Source_Serif_4/static/SourceSerif4-BoldItalic.ttf",
weight: "700",
style: "italic",
},
],
variable: "--font-source-serif-4",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Re-Quest Admin", title: "Re-Quest Admin",
description: "Admin dashboard for Re-Quest Platform", description: "Admin dashboard for Re-Quest Platform",
@@ -26,7 +131,7 @@ export default function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} ${sourceSerif4.variable} ${fractul.variable} antialiased`}
> >
<AuthProvider>{children}</AuthProvider> <AuthProvider>{children}</AuthProvider>
</body> </body>

View File

@@ -35,21 +35,21 @@ export default function LoginPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-100"> <div className="min-h-screen flex items-center justify-center bg-[#f5f9f8]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#f5a623]"></div>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-100"> <div className="min-h-screen flex items-center justify-center bg-[#f5f9f8]">
<div className="max-w-md w-full mx-4"> <div className="max-w-md w-full mx-4">
<div className="bg-white rounded-lg shadow-lg p-8"> <div className="bg-white rounded-2xl shadow-lg p-8 border border-[#e5e7eb]">
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="mx-auto w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center mb-4"> <div className="mx-auto w-16 h-16 bg-[#00293d] rounded-2xl flex items-center justify-center mb-4 shadow-md">
<svg <svg
className="w-8 h-8 text-white" className="w-8 h-8 text-[#f5a623]"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -58,17 +58,17 @@ export default function LoginPage() {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/> />
</svg> </svg>
</div> </div>
<h1 className="text-2xl font-bold text-gray-900">Re-Quest Admin</h1> <h1 className="text-2xl font-bold text-[#00293d] font-fractul">Re-Quest Admin</h1>
<p className="text-gray-500 mt-2">Re-Quest Platform Administration</p> <p className="text-[#666666] mt-2 font-serif">Platform Administration</p>
</div> </div>
{/* Error Alert */} {/* Error Alert */}
{error && ( {error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg"> <div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
<div className="flex items-center"> <div className="flex items-center">
<svg <svg
className="w-5 h-5 text-red-500 mr-2" className="w-5 h-5 text-red-500 mr-2"
@@ -91,7 +91,7 @@ export default function LoginPage() {
<div> <div>
<label <label
htmlFor="email" htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-2" className="block text-sm font-medium text-[#00293d] mb-2 font-serif"
> >
Email Address Email Address
</label> </label>
@@ -101,15 +101,15 @@ export default function LoginPage() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-gray-900 placeholder:text-gray-400 bg-white" className="w-full px-4 py-3 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] transition-colors text-gray-900 placeholder:text-gray-400 bg-white"
placeholder="admin@realestate.com" placeholder="admin@re-quest.com"
/> />
</div> </div>
<div> <div>
<label <label
htmlFor="password" htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-2" className="block text-sm font-medium text-[#00293d] mb-2 font-serif"
> >
Password Password
</label> </label>
@@ -119,7 +119,7 @@ export default function LoginPage() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-gray-900 placeholder:text-gray-400 bg-white" className="w-full px-4 py-3 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] transition-colors text-gray-900 placeholder:text-gray-400 bg-white"
placeholder="Enter your password" placeholder="Enter your password"
/> />
</div> </div>
@@ -127,7 +127,7 @@ export default function LoginPage() {
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center" className="w-full py-3 px-4 bg-[#f5a623] hover:bg-[#e09620] disabled:bg-[#f5a623]/60 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center shadow-sm hover:shadow-md font-fractul"
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
@@ -160,7 +160,7 @@ export default function LoginPage() {
{/* Footer */} {/* Footer */}
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-xs text-gray-500"> <p className="text-xs text-[#9ca3af] font-serif">
Only administrators can access this portal Only administrators can access this portal
</p> </p>
</div> </div>

View File

@@ -19,8 +19,8 @@ export default function Home() {
}, [isAuthenticated, isLoading, router]); }, [isAuthenticated, isLoading, router]);
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-100"> <div className="min-h-screen flex items-center justify-center bg-[#f5f9f8]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#f5a623]"></div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,101 @@
'use client';
interface SectionIconProps {
name: string | null | undefined;
className?: string;
}
// Map icon names to SVG paths
const iconPaths: Record<string, string> = {
// User/Person icons
'user': 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
'person': 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
// Contact icons
'phone': 'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z',
'telephone': 'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z',
// Location icons
'map-pin': 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z',
'location': 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z',
'location_on': 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z',
// Building icons
'building': 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
'office': 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
// House icons
'home': 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
'house': 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
// Work icons
'briefcase': 'M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
'work': 'M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
// Education icons
'academic-cap': 'M12 14l9-5-9-5-9 5 9 5z M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z M12 14l9-5-9-5-9 5 9 5zm0 0l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14zm-4 6v-7.5l4-2.222',
'education': 'M12 14l9-5-9-5-9 5 9 5z M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z',
// Bank icons
'bank': 'M3 21h18M3 10h18M5 6l7-3 7 3M4 10v11M20 10v11M8 14v3M12 14v3M16 14v3',
'library': 'M3 21h18M3 10h18M5 6l7-3 7 3M4 10v11M20 10v11M8 14v3M12 14v3M16 14v3',
// Experience icons
'star': 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
'experience': 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
// Document icons
'document': 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
'file': 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
// Mail icons
'mail': 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
'email': 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
// Globe icons
'globe': 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
'world': 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
// Settings/cog icons
'cog': 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
'settings': 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
// Key icons
'key': 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
// Info icons
'info': 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
'information': 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
// Default/fallback
'default': 'M4 6h16M4 10h16M4 14h16M4 18h16',
};
export default function SectionIcon({ name, className = 'w-5 h-5' }: SectionIconProps) {
if (!name) return null;
const iconName = name.toLowerCase().replace(/\s+/g, '-');
const path = iconPaths[iconName] || iconPaths['default'];
// Handle multi-path icons (like map-pin)
const paths = path.split(' M').map((p, i) => i === 0 ? p : 'M' + p);
return (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
{paths.map((p, i) => (
<path
key={i}
strokeLinecap="round"
strokeLinejoin="round"
d={p}
/>
))}
</svg>
);
}

View File

@@ -2,6 +2,10 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useState, useEffect, useCallback } from 'react';
import { supportChatService } from '@/services/support-chat.service';
import { adminSocketService } from '@/services/socket.service';
import { useAuth } from '@/context/AuthContext';
const menuItems = [ const menuItems = [
{ {
@@ -32,17 +36,179 @@ const menuItems = [
</svg> </svg>
), ),
}, },
{
name: 'Agent Types',
href: '/dashboard/agent-types',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
),
},
{
name: 'Profile Sections',
href: '/dashboard/profile-sections',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
),
},
{
name: 'Content Management',
href: '/dashboard/cms',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
),
},
{
name: 'Subscriptions',
href: '/dashboard/subscriptions',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
),
},
{
name: 'Subscription Plans',
href: '/dashboard/subscription-plans',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
),
},
{
name: 'Contact Messages',
href: '/dashboard/contact-messages',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
),
},
{
name: 'User Reports',
href: '/dashboard/reports',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
),
},
{
name: 'Audit Log',
href: '/dashboard/audit-log',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9h6m-6 4h6m-6-8h6"
/>
</svg>
),
},
{
name: 'Support Chat',
href: '/dashboard/support-chat',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
),
},
]; ];
export default function Sidebar() { export default function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { isSuperAdmin } = useAuth();
const [unreadCount, setUnreadCount] = useState(0);
const fetchUnreadCount = useCallback(async () => {
try {
const count = await supportChatService.getUnreadTotal();
setUnreadCount(count);
} catch {
// Silently fail
}
}, []);
useEffect(() => {
// Initial fetch
fetchUnreadCount();
// Connect WebSocket for real-time updates
adminSocketService.connect();
const handleUnreadUpdate = (data: unknown) => {
const payload = data as { unreadTotal: number };
if (typeof payload?.unreadTotal === 'number') {
setUnreadCount(payload.unreadTotal);
}
};
adminSocketService.on('support_unread_update', handleUnreadUpdate);
// Fallback polling every 30s (in case socket disconnects)
const interval = setInterval(fetchUnreadCount, 30000);
return () => {
clearInterval(interval);
adminSocketService.off('support_unread_update', handleUnreadUpdate);
};
}, [fetchUnreadCount]);
return ( return (
<aside className="w-64 bg-gray-900 min-h-screen fixed left-0 top-0"> <aside className="w-64 bg-[#00293d] min-h-screen fixed left-0 top-0">
{/* Logo */} {/* Logo */}
<div className="p-6 border-b border-gray-800"> <div className="p-6 border-b border-[#003a57]">
<div className="flex items-center"> <div className="flex items-center">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center mr-3"> <div className="w-10 h-10 bg-[#f5a623] rounded-lg flex items-center justify-center mr-3 shadow-sm">
<svg <svg
className="w-6 h-6 text-white" className="w-6 h-6 text-white"
fill="none" fill="none"
@@ -58,35 +224,58 @@ export default function Sidebar() {
</svg> </svg>
</div> </div>
<div> <div>
<h1 className="text-lg font-bold text-white">Re-Quest</h1> <h1 className="text-lg font-bold text-white font-fractul">Re-Quest</h1>
<p className="text-xs text-gray-400">Admin Panel</p> <p className="text-xs text-[#e8f0ee]">Admin Panel</p>
</div> </div>
</div> </div>
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="p-4"> <nav className="p-4">
<ul className="space-y-2"> <ul className="space-y-1">
{menuItems.map((item) => { {menuItems.map((item) => {
const isActive = pathname === item.href || const isActive = pathname === item.href ||
(item.href !== '/dashboard' && pathname.startsWith(item.href)); (item.href !== '/dashboard' && pathname.startsWith(item.href));
const showBadge = item.name === 'Support Chat' && unreadCount > 0;
return ( return (
<li key={item.name}> <li key={item.name}>
<Link <Link
href={item.href} href={item.href}
className={`flex items-center px-4 py-3 rounded-lg transition-colors ${ className={`flex items-center px-4 py-3 rounded-lg transition-all duration-200 ${
isActive isActive
? 'bg-blue-600 text-white' ? 'bg-[#f5a623] text-white shadow-sm'
: 'text-gray-300 hover:bg-gray-800 hover:text-white' : 'text-[#c4d9d4] hover:bg-[#003a57] hover:text-white'
}`} }`}
> >
{item.icon} {item.icon}
<span className="ml-3 font-medium">{item.name}</span> <span className="ml-3 font-medium font-serif text-sm">{item.name}</span>
{showBadge && (
<span className="ml-auto inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-bold text-white bg-red-500 rounded-full">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</Link> </Link>
</li> </li>
); );
})} })}
{isSuperAdmin && (
<li>
<Link
href="/dashboard/admin-users"
className={`flex items-center px-4 py-3 rounded-lg transition-all duration-200 ${
pathname === '/dashboard/admin-users'
? 'bg-[#f5a623] text-white shadow-sm'
: 'text-[#c4d9d4] hover:bg-[#003a57] hover:text-white'
}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="ml-3 font-medium font-serif text-sm">Admin Users</span>
</Link>
</li>
)}
</ul> </ul>
</nav> </nav>
</aside> </aside>

View File

@@ -8,6 +8,7 @@ interface AuthContextType {
user: AuthUser | null; user: AuthUser | null;
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
isSuperAdmin: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
error: string | null; error: string | null;
@@ -25,7 +26,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try { try {
const storedUser = authService.getStoredUser(); const storedUser = authService.getStoredUser();
if (storedUser && storedUser.role === 'ADMIN') { if (storedUser && (storedUser.role === 'ADMIN' || storedUser.role === 'SUPER_ADMIN')) {
setUser(storedUser); setUser(storedUser);
} else if (storedUser) { } else if (storedUser) {
// Not admin, clear storage // Not admin, clear storage
@@ -47,6 +48,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setError(null); setError(null);
try { try {
const response = await authService.login({ email, password }); const response = await authService.login({ email, password });
// Block non-admin users from admin panel
if (response.user.role !== 'ADMIN' && response.user.role !== 'SUPER_ADMIN') {
authService.clearAuth();
throw new Error('Access denied. Only admin users can login here.');
}
setUser(response.user); setUser(response.user);
router.push('/dashboard'); router.push('/dashboard');
} catch (err) { } catch (err) {
@@ -71,6 +79,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
user, user,
isLoading, isLoading,
isAuthenticated: !!user, isAuthenticated: !!user,
isSuperAdmin: user?.role === 'SUPER_ADMIN',
login, login,
logout, logout,
error, error,

View File

@@ -0,0 +1,91 @@
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,128 @@
Source Serif 4 Variable Font
============================
This download contains Source Serif 4 as both variable fonts and static fonts.
Source Serif 4 is a variable font with these axes:
opsz
wght
This means all the styles are contained in these files:
SourceSerif4-VariableFont_opsz,wght.ttf
SourceSerif4-Italic-VariableFont_opsz,wght.ttf
If your app fully supports variable fonts, you can now pick intermediate styles
that arent available as static fonts. Not all apps support variable fonts, and
in those cases you can use the static font files for Source Serif 4:
static/SourceSerif4-ExtraLight.ttf
static/SourceSerif4-Light.ttf
static/SourceSerif4-Regular.ttf
static/SourceSerif4-Medium.ttf
static/SourceSerif4-SemiBold.ttf
static/SourceSerif4-Bold.ttf
static/SourceSerif4-ExtraBold.ttf
static/SourceSerif4-Black.ttf
static/SourceSerif4_18pt-ExtraLight.ttf
static/SourceSerif4_18pt-Light.ttf
static/SourceSerif4_18pt-Regular.ttf
static/SourceSerif4_18pt-Medium.ttf
static/SourceSerif4_18pt-SemiBold.ttf
static/SourceSerif4_18pt-Bold.ttf
static/SourceSerif4_18pt-ExtraBold.ttf
static/SourceSerif4_18pt-Black.ttf
static/SourceSerif4_36pt-ExtraLight.ttf
static/SourceSerif4_36pt-Light.ttf
static/SourceSerif4_36pt-Regular.ttf
static/SourceSerif4_36pt-Medium.ttf
static/SourceSerif4_36pt-SemiBold.ttf
static/SourceSerif4_36pt-Bold.ttf
static/SourceSerif4_36pt-ExtraBold.ttf
static/SourceSerif4_36pt-Black.ttf
static/SourceSerif4_48pt-ExtraLight.ttf
static/SourceSerif4_48pt-Light.ttf
static/SourceSerif4_48pt-Regular.ttf
static/SourceSerif4_48pt-Medium.ttf
static/SourceSerif4_48pt-SemiBold.ttf
static/SourceSerif4_48pt-Bold.ttf
static/SourceSerif4_48pt-ExtraBold.ttf
static/SourceSerif4_48pt-Black.ttf
static/SourceSerif4-ExtraLightItalic.ttf
static/SourceSerif4-LightItalic.ttf
static/SourceSerif4-Italic.ttf
static/SourceSerif4-MediumItalic.ttf
static/SourceSerif4-SemiBoldItalic.ttf
static/SourceSerif4-BoldItalic.ttf
static/SourceSerif4-ExtraBoldItalic.ttf
static/SourceSerif4-BlackItalic.ttf
static/SourceSerif4_18pt-ExtraLightItalic.ttf
static/SourceSerif4_18pt-LightItalic.ttf
static/SourceSerif4_18pt-Italic.ttf
static/SourceSerif4_18pt-MediumItalic.ttf
static/SourceSerif4_18pt-SemiBoldItalic.ttf
static/SourceSerif4_18pt-BoldItalic.ttf
static/SourceSerif4_18pt-ExtraBoldItalic.ttf
static/SourceSerif4_18pt-BlackItalic.ttf
static/SourceSerif4_36pt-ExtraLightItalic.ttf
static/SourceSerif4_36pt-LightItalic.ttf
static/SourceSerif4_36pt-Italic.ttf
static/SourceSerif4_36pt-MediumItalic.ttf
static/SourceSerif4_36pt-SemiBoldItalic.ttf
static/SourceSerif4_36pt-BoldItalic.ttf
static/SourceSerif4_36pt-ExtraBoldItalic.ttf
static/SourceSerif4_36pt-BlackItalic.ttf
static/SourceSerif4_48pt-ExtraLightItalic.ttf
static/SourceSerif4_48pt-LightItalic.ttf
static/SourceSerif4_48pt-Italic.ttf
static/SourceSerif4_48pt-MediumItalic.ttf
static/SourceSerif4_48pt-SemiBoldItalic.ttf
static/SourceSerif4_48pt-BoldItalic.ttf
static/SourceSerif4_48pt-ExtraBoldItalic.ttf
static/SourceSerif4_48pt-BlackItalic.ttf
Get started
-----------
1. Install the font files you want to use
2. Use your app's font picker to view the font family and all the
available styles
Learn more about variable fonts
-------------------------------
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
https://variablefonts.typenetwork.com
https://medium.com/variable-fonts
In desktop apps
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
Online
https://developers.google.com/fonts/docs/getting_started
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
Installing fonts
MacOS: https://support.apple.com/en-us/HT201749
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
Android Apps
https://developers.google.com/fonts/docs/android
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
License
-------
Please read the full license text (OFL.txt) to understand the permissions,
restrictions and requirements for usage, redistribution, and modification.
You can use them in your products & projects print or digital,
commercial or otherwise.
This isn't legal advice, please consider consulting a lawyer and see the full
license for all details.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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