diff --git a/.gitignore b/.gitignore index a547bf36d..d5aa5887d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ dist-ssr *.njsproj *.sln *.sw? +.env* +.env +.env.local +.env.example diff --git a/README.md b/README.md new file mode 100644 index 000000000..a454f805b --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# Business Nexus + +A comprehensive web platform for business connections, connecting startups, investors, and professionals. + +## Tech Stack + +**Frontend:** + +- React +- TypeScript +- Vite +- Tailwind CSS +- Lucide React (Icons) + +**Backend:** + +- Node.js +- Express +- MongoDB (Mongoose) +- TypeScript + +## Prerequisites + +Before running this project, ensure you have the following installed: + +- [Node.js](https://nodejs.org/) (v16+ recommended) +- [MongoDB](https://www.mongodb.com/) (running locally or a cloud instance like MongoDB Atlas) + +## Installation + +1. **Clone the repository:** + + ```bash + git clone + cd Nexus + ``` + +2. **Install dependencies:** + ```bash + npm install + ``` + +## Configuration + +1. **Backend Environment Variables:** + Create a `.env` file in the root directory based on `.env.example`: + + ```bash + cp .env.example .env + ``` + + Update the `.env` file with your specific configuration, particularly the `MONGODB_URL` if you are not using a local default instance. + + ```env + PORT=3001 + MONGODB_URL=mongodb://localhost:27017/business-nexus + ``` + +## Running the Project + +You will need to run the backend and frontend in separate terminal windows. + +### 1. Start the Backend Server + +The backend requires a running MongoDB instance. Make sure MongoDB is started. + +In the first terminal window, run: + +```bash +npx tsx watch backend/server.ts +``` + +The server should start on port `3001` and connect to MongoDB. + +### 2. Start the Frontend Application + +In a second terminal window, run: + +```bash +npm run dev +``` + +The frontend will start (usually on `http://localhost:5173`) and will automatically connect to the backend at `http://localhost:3001`. + +## Building for Production + +To build the frontend for production: + +```bash +npm run build +``` + +## Project Structure + +- `src/` - Frontend source code (React components, pages, services) +- `backend/` - Backend source code (Express server, Mongoose models, routes) +- `public/` - Static assets diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..034e84803 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. diff --git a/backend/config/swaggerConfig.ts b/backend/config/swaggerConfig.ts new file mode 100644 index 000000000..4ebbd7740 --- /dev/null +++ b/backend/config/swaggerConfig.ts @@ -0,0 +1,55 @@ +import swaggerJsdoc from "swagger-jsdoc"; + +const options: swaggerJsdoc.Options = { + definition: { + openapi: "3.0.0", + info: { + title: "Business Nexus API", + version: "1.0.0", + description: "API documentation for the Business Nexus platform", + contact: { + name: "Support", + url: "http://localhost:5173/help", + }, + }, + servers: [ + { + url: "http://localhost:3001/api", + description: "Development Server", + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + }, + Transaction: { + type: "object", + properties: { + id: { type: "string" }, + userId: { type: "string" }, + type: { type: "string", enum: ["deposit", "withdraw", "transfer"] }, + amount: { type: "number" }, + status: { + type: "string", + enum: ["pending", "completed", "failed"], + }, + description: { type: "string" }, + recipientId: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + apis: ["./backend/routes/*.ts"], // Path to the API docs (it scans JSDoc comments) +}; + +export const specs = swaggerJsdoc(options); diff --git a/backend/database.ts b/backend/database.ts new file mode 100644 index 000000000..6b99b63cd --- /dev/null +++ b/backend/database.ts @@ -0,0 +1,64 @@ +import mongoose from "mongoose"; +import dotenv from "dotenv"; +import bcrypt from "bcryptjs"; +import { entrepreneurs, investors } from "../src/data/users"; +import { messages } from "../src/data/messages"; +import { collaborationRequests } from "../src/data/collaborationRequests"; + +import User from "./models/User.model"; +import Message from "./models/Message.model"; +import CollaborationRequest from "./models/CollaborationRequest.model"; + +dotenv.config(); + +const MONGO_URL = process.env.MONGODB_URL; + +if (!MONGO_URL) { + console.error("MONGODB_URL not found in .env"); + process.exit(1); +} + +async function database() { + try { + await mongoose.connect(MONGO_URL as string); + console.log("Connected to MongoDB for database..."); + + // Clear existing data + await User.deleteMany({}); + await Message.deleteMany({}); + await CollaborationRequest.deleteMany({}); + console.log("Cleared existing collections."); + + // Hash a default password for all mock users + const defaultPassword = await bcrypt.hash("password123", 10); + + // Seed Users + const allUsers = [...entrepreneurs, ...investors].map((user) => ({ + ...user, + password: defaultPassword, + })); + + await User.insertMany(allUsers); + console.log( + `Successfully added ${allUsers.length} users with hashed passwords.`, + ); + + // Seed Messages + await Message.insertMany(messages); + console.log(`Successfully added ${messages.length} messages.`); + + // Seed Collaboration Requests + await CollaborationRequest.insertMany(collaborationRequests); + console.log( + `Successfully added ${collaborationRequests.length} collaboration requests.`, + ); + + console.log("Database added successfully!"); + process.exit(0); + } catch (error) { + console.error("Error adding to database:", error); + process.exit(1); + } +} + +database(); diff --git a/backend/middlewares/auth.middleware.ts b/backend/middlewares/auth.middleware.ts new file mode 100644 index 000000000..f662943ce --- /dev/null +++ b/backend/middlewares/auth.middleware.ts @@ -0,0 +1,41 @@ +import { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET || "your_fallback_secret"; + +export interface AuthRequest extends Request { + user?: { + userId: string; + role: string; + }; +} + +export const authenticateToken = ( + req: AuthRequest, + res: Response, + next: NextFunction, +) => { + const authHeader = req.headers["authorization"]; + const token = authHeader && authHeader.split(" ")[1]; + + if (!token) { + return res.status(401).json({ message: "No token provided" }); + } + + jwt.verify(token, JWT_SECRET, (err, decoded) => { + if (err) { + return res.status(403).json({ message: "Invalid or expired token" }); + } + req.user = decoded as { userId: string; role: string }; + next(); + }); +}; + +export const checkRole = (roles: string[]) => { + return (req: AuthRequest, res: Response, next: NextFunction) => { + if (!req.user || !roles.includes(req.user.role)) { + return res.status(403).json({ message: "Access denied" }); + } + next(); + }; +}; diff --git a/backend/middlewares/validation.middleware.ts b/backend/middlewares/validation.middleware.ts new file mode 100644 index 000000000..9ac151e00 --- /dev/null +++ b/backend/middlewares/validation.middleware.ts @@ -0,0 +1,131 @@ +import { body, validationResult } from "express-validator"; +import { Request, Response, NextFunction } from "express"; + +export const validateRequest = ( + req: Request, + res: Response, + next: NextFunction, +) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + message: "Validation failed", + errors: errors.array(), + }); + } + next(); +}; + +export const registerSchema = [ + body("name") + .trim() + .notEmpty() + .withMessage("Name is required") + .isLength({ min: 2, max: 50 }) + .withMessage("Name must be between 2 and 50 characters") + .escape(), + body("email") + .trim() + .notEmpty() + .withMessage("Email is required") + .isEmail() + .withMessage("Please enter a valid email") + .normalizeEmail(), + body("password") + .notEmpty() + .withMessage("Password is required") + .isLength({ min: 8 }) + .withMessage("Password must be at least 8 characters long") + .matches(/\d/) + .withMessage("Password must contain at least one number") + .matches(/[a-z]/) + .withMessage("Password must contain at least one lowercase letter") + .matches(/[A-Z]/) + .withMessage("Password must contain at least one uppercase letter"), + body("role") + .isIn(["entrepreneur", "investor"]) + .withMessage("Invalid role selection"), +]; + +export const loginSchema = [ + body("email") + .trim() + .notEmpty() + .withMessage("Email is required") + .isEmail() + .withMessage("Please enter a valid email"), + body("password").notEmpty().withMessage("Password is required"), +]; + +export const profileUpdateSchema = [ + body("name") + .optional() + .trim() + .isLength({ min: 2, max: 50 }) + .withMessage("Name must be between 2 and 50 characters") + .escape(), + body("bio") + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage("Bio must not exceed 500 characters") + .escape(), + body("location") + .optional() + .trim() + .isLength({ max: 100 }) + .withMessage("Location must not exceed 100 characters") + .escape(), +]; + +export const changePasswordSchema = [ + body("currentPassword") + .notEmpty() + .withMessage("Current password is required"), + body("newPassword") + .notEmpty() + .withMessage("New password is required") + .isLength({ min: 8 }) + .withMessage("New password must be at least 8 characters long") + .matches(/\d/) + .withMessage("New password must contain at least one number") + .matches(/[a-z]/) + .withMessage("New password must contain at least one lowercase letter") + .matches(/[A-Z]/) + .withMessage("New password must contain at least one uppercase letter") + .custom((value, { req }) => { + if (value === req.body.currentPassword) { + throw new Error("New password must be different from current password"); + } + return true; + }), +]; + +export const collaborationSchema = [ + body("entrepreneurId").notEmpty().withMessage("Entrepreneur ID is required"), + body("message") + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage("Message must not exceed 500 characters") + .escape(), +]; + +export const transactionSchema = [ + body("amount") + .notEmpty() + .withMessage("Amount is required") + .isFloat({ min: 0.01 }) + .withMessage("Amount must be a positive number"), + body("method").optional().trim().escape(), + body("description") + .optional() + .trim() + .isLength({ max: 200 }) + .withMessage("Description must not exceed 200 characters") + .escape(), + body("recipientId") + .if(body("type").equals("transfer")) + .notEmpty() + .withMessage("Recipient ID is required for transfers"), +]; diff --git a/backend/models/CollaborationRequest.model.ts b/backend/models/CollaborationRequest.model.ts new file mode 100644 index 000000000..18a5c619c --- /dev/null +++ b/backend/models/CollaborationRequest.model.ts @@ -0,0 +1,26 @@ +import mongoose, { Schema, Document } from "mongoose"; +import { CollaborationRequest } from "../../src/types"; + +export interface ICollaborationRequestDocument + extends Omit, Document { + id: string; +} + +const CollaborationRequestSchema: Schema = new Schema({ + id: { type: String, required: true, unique: true }, + investorId: { type: String, required: true }, + entrepreneurId: { type: String, required: true }, + message: { type: String, required: true }, + status: { + type: String, + enum: ["pending", "accepted", "rejected"], + default: "pending", + }, + createdAt: { type: String, required: true }, +}); + +export default mongoose.models.CollaborationRequest || + mongoose.model( + "CollaborationRequest", + CollaborationRequestSchema, + ); diff --git a/backend/models/Deal.model.ts b/backend/models/Deal.model.ts new file mode 100644 index 000000000..ab98edf86 --- /dev/null +++ b/backend/models/Deal.model.ts @@ -0,0 +1,41 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface IDeal { + id: string; + investorId: string; + entrepreneurId: string; + amount: string; + equity: string; + status: "Due Diligence" | "Term Sheet" | "Negotiation" | "Closed" | "Passed"; + stage: string; + notes?: string; + createdAt: string; + lastActivity: string; +} + +export interface IDealDocument extends Omit, Document { + id: string; +} + +const DealSchema: Schema = new Schema({ + id: { type: String, required: true, unique: true }, + investorId: { type: String, required: true }, + entrepreneurId: { type: String, required: true }, + amount: { type: String, required: true }, + equity: { type: String, required: true }, + status: { + type: String, + enum: ["Due Diligence", "Term Sheet", "Negotiation", "Closed", "Passed"], + default: "Due Diligence", + }, + stage: { type: String, required: true }, + notes: { type: String }, + createdAt: { type: String, default: () => new Date().toISOString() }, + lastActivity: { type: String, default: () => new Date().toISOString() }, +}); + +// Index for fetching deals for a specific investor +DealSchema.index({ investorId: 1, lastActivity: -1 }); + +export default mongoose.models.Deal || + mongoose.model("Deal", DealSchema); diff --git a/backend/models/Document.model.ts b/backend/models/Document.model.ts new file mode 100644 index 000000000..6f7b69317 --- /dev/null +++ b/backend/models/Document.model.ts @@ -0,0 +1,33 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface IDocument { + id: string; + userId: string; + name: string; + type: string; + size: string; + content: string; // Base64 content + shared: boolean; + createdAt: string; +} + +export interface IDocumentDocument extends Omit, Document { + id: string; +} + +const DocumentSchema: Schema = new Schema({ + id: { type: String, required: true, unique: true }, + userId: { type: String, required: true }, + name: { type: String, required: true }, + type: { type: String, required: true }, + size: { type: String, required: true }, + content: { type: String, required: true }, + shared: { type: Boolean, default: false }, + createdAt: { type: String, default: () => new Date().toISOString() }, +}); + +// Index for fetching documents for a specific user +DocumentSchema.index({ userId: 1, createdAt: -1 }); + +export default mongoose.models.Document || + mongoose.model("Document", DocumentSchema); diff --git a/backend/models/Meeting.model.ts b/backend/models/Meeting.model.ts new file mode 100644 index 000000000..70e1776b7 --- /dev/null +++ b/backend/models/Meeting.model.ts @@ -0,0 +1,42 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface IMeeting { + id: string; + title: string; + description?: string; + investorId: string; + entrepreneurId: string; + startTime: string; + endTime: string; + status: "pending" | "accepted" | "rejected" | "cancelled"; + location?: string; + createdAt: string; +} + +export interface IMeetingDocument extends Omit, Document { + id: string; +} + +const MeetingSchema: Schema = new Schema({ + id: { type: String, required: true, unique: true }, + title: { type: String, required: true }, + description: { type: String }, + investorId: { type: String, required: true }, + entrepreneurId: { type: String, required: true }, + startTime: { type: String, required: true }, + endTime: { type: String, required: true }, + status: { + type: String, + enum: ["pending", "accepted", "rejected", "cancelled"], + default: "pending", + }, + location: { type: String }, + createdAt: { type: String, default: () => new Date().toISOString() }, +}); + +// Index for conflict detection queries +MeetingSchema.index({ investorId: 1, startTime: 1, endTime: 1 }); +MeetingSchema.index({ entrepreneurId: 1, startTime: 1, endTime: 1 }); + +export default mongoose.models.Meeting || + mongoose.model("Meeting", MeetingSchema); diff --git a/backend/models/Message.model.ts b/backend/models/Message.model.ts new file mode 100644 index 000000000..efa16fa54 --- /dev/null +++ b/backend/models/Message.model.ts @@ -0,0 +1,18 @@ +import mongoose, { Schema, Document } from "mongoose"; +import { Message } from "../../src/types"; + +export interface IMessageDocument extends Omit, Document { + id: string; +} + +const MessageSchema: Schema = new Schema({ + id: { type: String, required: true, unique: true }, + senderId: { type: String, required: true }, + receiverId: { type: String, required: true }, + content: { type: String, required: true }, + timestamp: { type: String, required: true }, + isRead: { type: Boolean, default: false }, +}); + +export default mongoose.models.Message || + mongoose.model("Message", MessageSchema); diff --git a/backend/models/Notification.model.ts b/backend/models/Notification.model.ts new file mode 100644 index 000000000..2c8422f0f --- /dev/null +++ b/backend/models/Notification.model.ts @@ -0,0 +1,49 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface INotification { + id: string; + userId: string; + type: + | "message" + | "collaboration_request" + | "collaboration_accepted" + | "meeting_scheduled" + | "meeting_status"; + title: string; + message: string; + link: string; + isRead: boolean; + createdAt: string; +} + +export interface INotificationDocument + extends Omit, Document { + id: string; +} + +const NotificationSchema: Schema = new Schema({ + id: { type: String, required: true, unique: true }, + userId: { type: String, required: true }, + type: { + type: String, + enum: [ + "message", + "collaboration_request", + "collaboration_accepted", + "meeting_scheduled", + "meeting_status", + ], + required: true, + }, + title: { type: String, required: true }, + message: { type: String, required: true }, + link: { type: String, required: true }, + isRead: { type: Boolean, default: false }, + createdAt: { type: String, default: () => new Date().toISOString() }, +}); + +// Index for fetching notifications for a specific user +NotificationSchema.index({ userId: 1, createdAt: -1 }); + +export default mongoose.models.Notification || + mongoose.model("Notification", NotificationSchema); diff --git a/backend/models/SupportTicket.model.ts b/backend/models/SupportTicket.model.ts new file mode 100644 index 000000000..aaf952d37 --- /dev/null +++ b/backend/models/SupportTicket.model.ts @@ -0,0 +1,46 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface ISupportTicket { + id: string; + userId: string; + name: string; + email: string; + subject: string; + message: string; + status: "open" | "in-progress" | "resolved" | "closed"; + priority: "low" | "medium" | "high"; + createdAt: string; + updatedAt: string; +} + +export interface ISupportTicketDocument + extends Omit, Document { + id: string; +} + +const SupportTicketSchema: Schema = new Schema({ + id: { type: String, required: true, unique: true }, + userId: { type: String, required: true }, + name: { type: String, required: true }, + email: { type: String, required: true }, + subject: { type: String, required: true }, + message: { type: String, required: true }, + status: { + type: String, + enum: ["open", "in-progress", "resolved", "closed"], + default: "open", + }, + priority: { + type: String, + enum: ["low", "medium", "high"], + default: "medium", + }, + createdAt: { type: String, default: () => new Date().toISOString() }, + updatedAt: { type: String, default: () => new Date().toISOString() }, +}); + +// Index for fetching tickets for a specific user +SupportTicketSchema.index({ userId: 1, createdAt: -1 }); + +export default mongoose.models.SupportTicket || + mongoose.model("SupportTicket", SupportTicketSchema); diff --git a/backend/models/Transaction.model.ts b/backend/models/Transaction.model.ts new file mode 100644 index 000000000..9563c1186 --- /dev/null +++ b/backend/models/Transaction.model.ts @@ -0,0 +1,39 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface ITransactionDocument extends Document { + id: string; // Custom ID for consistency + userId: string; + type: "deposit" | "withdraw" | "transfer"; + amount: number; + status: "pending" | "completed" | "failed"; + description: string; + recipientId?: string; + createdAt: string; +} + +const TransactionSchema: Schema = new Schema( + { + id: { type: String, unique: true, sparse: true }, + userId: { type: String, required: true }, + type: { + type: String, + enum: ["deposit", "withdraw", "transfer"], + required: true, + }, + amount: { type: Number, required: true }, + status: { + type: String, + enum: ["pending", "completed", "failed"], + default: "completed", + }, + description: { type: String, required: true }, + recipientId: { type: String }, + createdAt: { type: String, default: () => new Date().toISOString() }, + }, + { + timestamps: false, + }, +); + +export default mongoose.models.Transaction || + mongoose.model("Transaction", TransactionSchema); diff --git a/backend/models/User.model.ts b/backend/models/User.model.ts new file mode 100644 index 000000000..910d1630d --- /dev/null +++ b/backend/models/User.model.ts @@ -0,0 +1,67 @@ +import mongoose, { Schema, Document } from "mongoose"; +import { User } from "../../src/types"; + +export interface IUserDocument extends Omit, Document { + id: string; // Keep the 'id' field for compatibility with frontend mock data + password: string; + startupName?: string; + pitchSummary?: string; + fundingNeeded?: string; + industry?: string; + location?: string; + foundedYear?: number; + teamSize?: number; + investmentInterests?: string[]; + investmentStage?: string[]; + portfolioCompanies?: string[]; + totalInvestments?: number; + minimumInvestment?: string; + maximumInvestment?: string; + twoFactorSecret?: string; + twoFactorRecoveryCodes?: string[]; + isTwoFactorEnabled: boolean; + walletBalance: number; +} + +const UserSchema: Schema = new Schema( + { + id: { type: String, required: true, unique: true }, + name: { type: String, required: true }, + email: { type: String, required: true, unique: true }, + password: { type: String, required: true }, + role: { type: String, enum: ["entrepreneur", "investor"], required: true }, + avatarUrl: { type: String, default: "https://via.placeholder.com/150" }, + bio: { type: String, default: "" }, + isOnline: { type: Boolean, default: false }, + createdAt: { type: String, default: () => new Date().toISOString() }, + + // Entrepreneur fields + startupName: { type: String }, + pitchSummary: { type: String }, + fundingNeeded: { type: String }, + industry: { type: String }, + location: { type: String }, + foundedYear: { type: Number }, + teamSize: { type: Number }, + + // Investor fields + investmentInterests: [{ type: String }], + investmentStage: [{ type: String }], + portfolioCompanies: [{ type: String }], + totalInvestments: { type: Number }, + minimumInvestment: { type: String }, + maximumInvestment: { type: String }, + walletBalance: { type: Number, default: 0 }, + + // 2FA fields + isTwoFactorEnabled: { type: Boolean, default: false }, + twoFactorSecret: { type: String }, + twoFactorRecoveryCodes: [{ type: String }], + }, + { + timestamps: false, // Using custom createdAt from mock data + }, +); + +export default mongoose.models.User || + mongoose.model("User", UserSchema); diff --git a/backend/routes/auth.routes.ts b/backend/routes/auth.routes.ts new file mode 100644 index 000000000..676e2bbad --- /dev/null +++ b/backend/routes/auth.routes.ts @@ -0,0 +1,354 @@ +import express from "express"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import User from "../models/User.model"; +import { + registerSchema, + loginSchema, + validateRequest, +} from "../middlewares/validation.middleware"; +import { sendOTP, sendResetPasswordEmail } from "../services/emailService"; +import { authenticateToken, AuthRequest } from "../middlewares/auth.middleware"; + +const router = express.Router(); + +const JWT_SECRET = process.env.JWT_SECRET || "your_fallback_secret"; + +/** + * @openapi + * /auth/register: + * post: + * summary: Register a new user + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name, email, password, role] + * properties: + * name: { type: string } + * email: { type: string } + * password: { type: string } + * role: { type: string, enum: [entrepreneur, investor] } + * responses: + * 201: + * description: User registered successfully + * 400: + * description: User already exists + */ +// User Registration +router.post("/register", registerSchema, validateRequest, async (req, res) => { + try { + const { name, email, password, role, ...extraInfo } = req.body; + + // Check if user already exists + const existingUser = await User.findOne({ email }); + if (existingUser) { + return res.status(400).json({ message: "User already exists" }); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Create new user + const newUser = new User({ + id: `u${Date.now()}`, + name, + email, + password: hashedPassword, + role, + avatarUrl: extraInfo.avatarUrl || "https://via.placeholder.com/150", + bio: extraInfo.bio || "", + createdAt: new Date().toISOString(), + ...extraInfo, + }); + + await newUser.save(); + + res.status(201).json({ message: "User registered successfully" }); + } catch (error) { + console.error("Registration error:", error); + res.status(500).json({ + message: "Server error", + error: error instanceof Error ? error.message : String(error), + }); + } +}); + +/** + * @openapi + * /auth/login: + * post: + * summary: Authenticate user and return token + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password] + * properties: + * email: { type: string } + * password: { type: string } + * responses: + * 200: + * description: Login successful + * 400: + * description: Invalid email or password + */ +// User Login +router.post("/login", loginSchema, validateRequest, async (req, res) => { + try { + const { email, password } = req.body; + console.log("Login attempt for:", email); + + // Find user + const user = await User.findOne({ email }); + if (!user) { + console.log("Login failed: User not found"); + return res.status(400).json({ message: "Invalid email or password" }); + } + + // Validate password + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + console.log("Login failed: Wrong password"); + return res.status(400).json({ message: "Invalid email or password" }); + } + + // Check for 2FA + if (user.isTwoFactorEnabled) { + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + // Store OTP and expiry in user doc for this session (mock approach) + await User.findOneAndUpdate( + { id: user.id }, + { + $set: { + twoFactorSecret: otp, // Using this field temporarily for the mockup OTP + }, + }, + ); + + await sendOTP(user.email, otp); + + const tempToken = jwt.sign( + { userId: user.id, isTemp: true }, + JWT_SECRET, + { + expiresIn: "5m", + }, + ); + + return res.json({ + requires2FA: true, + tempToken, + email: user.email, + }); + } + + // Create JWT + const token = jwt.sign({ userId: user.id, role: user.role }, JWT_SECRET, { + expiresIn: "1d", + }); + + console.log("Login successful for:", email); + res.json({ + token, + user: { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + }, + }); + } catch (error) { + console.error("Login error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +/** + * @openapi + * /auth/forgot-password: + * post: + * summary: Send password reset email + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: { type: string } + * responses: + * 200: + * description: Password reset instructions sent + * 404: + * description: User not found + */ +// Forgot Password +router.post("/forgot-password", async (req, res) => { + try { + const { email } = req.body; + const user = await User.findOne({ email }); + + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + const resetToken = jwt.sign({ userId: user.id }, JWT_SECRET, { + expiresIn: "10m", + }); + + await sendResetPasswordEmail(user.email, resetToken); + + res.json({ message: "Password reset instructions sent", resetToken }); + } catch (error) { + console.error("Forgot password error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Reset Password +router.post("/reset-password", async (req, res) => { + try { + const { token, newPassword } = req.body; + + const decoded = jwt.verify(token, JWT_SECRET) as { userId: string }; + const hashedPassword = await bcrypt.hash(newPassword, 10); + + const user = await User.findOneAndUpdate( + { id: decoded.userId }, + { $set: { password: hashedPassword } }, + { new: true }, + ); + + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + res.json({ message: "Password reset successful" }); + } catch (error) { + console.error("Reset password error:", error); + res.status(400).json({ message: "Invalid or expired token" }); + } +}); + +// 2FA Setup - Send OTP to email +router.post("/2fa/setup", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const user = await User.findOne({ id: userId }); + + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + await User.findOneAndUpdate( + { id: userId }, + { $set: { twoFactorSecret: otp } }, + ); + + await sendOTP(user.email, otp); + res.json({ message: "OTP sent to your email" }); + } catch (error) { + console.error("2FA setup error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// 2FA Verify & Enable +router.post("/2fa/enable", authenticateToken, async (req: AuthRequest, res) => { + try { + const { otp } = req.body; + const userId = req.user?.userId; + const user = await User.findOne({ id: userId }); + + if (!user || user.twoFactorSecret !== otp) { + return res.status(400).json({ message: "Invalid verification code" }); + } + + await User.findOneAndUpdate( + { id: userId }, + { + $set: { isTwoFactorEnabled: true }, + $unset: { twoFactorSecret: "" }, + }, + ); + + res.json({ message: "Two-factor authentication enabled successfully" }); + } catch (error) { + console.error("2FA enable error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// 2FA Disable +router.post( + "/2fa/disable", + authenticateToken, + async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + await User.findOneAndUpdate( + { id: userId }, + { $set: { isTwoFactorEnabled: false } }, + ); + res.json({ message: "Two-factor authentication disabled" }); + } catch (error) { + console.error("2FA disable error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +// 2FA Validate during login +router.post("/2fa/validate-login", async (req, res) => { + try { + const { tempToken, otp } = req.body; + + const decoded = jwt.verify(tempToken, JWT_SECRET) as { + userId: string; + isTemp: boolean; + }; + if (!decoded.isTemp) { + return res.status(401).json({ message: "Invalid session" }); + } + + const user = await User.findOne({ id: decoded.userId }); + if (!user || user.twoFactorSecret !== otp) { + return res.status(400).json({ message: "Invalid verification code" }); + } + + // Success - Clear temp OTP and return final JWT + await User.findOneAndUpdate( + { id: user.id }, + { $unset: { twoFactorSecret: "" } }, + ); + + const token = jwt.sign({ userId: user.id, role: user.role }, JWT_SECRET, { + expiresIn: "1d", + }); + + res.json({ + token, + user: { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + }, + }); + } catch (error) { + console.error("2FA validation error:", error); + res.status(401).json({ message: "Session expired or invalid" }); + } +}); + +export default router; diff --git a/backend/routes/collaboration.routes.ts b/backend/routes/collaboration.routes.ts new file mode 100644 index 000000000..b909ba1e1 --- /dev/null +++ b/backend/routes/collaboration.routes.ts @@ -0,0 +1,157 @@ +import express, { Response } from "express"; +import CollaborationRequest from "../models/CollaborationRequest.model"; +import User from "../models/User.model"; +import Notification from "../models/Notification.model"; +import { authenticateToken, AuthRequest } from "../middlewares/auth.middleware"; +import { + collaborationSchema, + validateRequest, +} from "../middlewares/validation.middleware"; + +const router = express.Router(); + +// Get requests for current user (as entrepreneur or investor) +router.get("/", authenticateToken, async (req: AuthRequest, res) => { + try { + const { userId, role } = req.user!; + let query = {}; + + if (role === "entrepreneur") { + query = { entrepreneurId: userId }; + } else { + query = { investorId: userId }; + } + + const requests = await CollaborationRequest.find(query) + .sort({ + createdAt: -1, + }) + .lean(); + + // Fetch user details for each request + const populatedRequests = await Promise.all( + requests.map(async (request) => { + const partnerId = + role === "entrepreneur" ? request.investorId : request.entrepreneurId; + const partner = await User.findOne({ id: partnerId }) + .select("id name email avatarUrl role isOnline") + .lean(); + return { + ...request, + partner, + }; + }), + ); + + res.json(populatedRequests); + } catch (error) { + console.error("Fetch requests error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Create a new request +router.post( + "/", + authenticateToken, + collaborationSchema, + validateRequest, + async (req: AuthRequest, res: Response) => { + try { + const { entrepreneurId, message } = req.body; + const { userId: investorId } = req.user!; + + console.log( + "[DEBUG] POST /collaboration - from (investor):", + investorId, + "to (entrepreneur):", + entrepreneurId, + ); + + if (!entrepreneurId) { + console.log("[DEBUG] POST /collaboration - entrepreneurId is missing!"); + return res.status(400).json({ message: "Entrepreneur ID is required" }); + } + + const newRequest = new CollaborationRequest({ + id: `req${Date.now()}`, + investorId, + entrepreneurId, + message, + status: "pending", + createdAt: new Date().toISOString(), + }); + + console.log( + "[DEBUG] POST /collaboration - saving new request:", + newRequest.id, + ); + await newRequest.save(); + + // Create notification for entrepreneur + const notification = new Notification({ + id: `notif${Date.now()}`, + userId: entrepreneurId, + type: "collaboration_request", + title: "New Collaboration Request", + message: `An investor has sent you a collaboration request.`, + link: "/dashboard/entrepreneur", + isRead: false, + }); + console.log( + "[DEBUG] POST /collaboration - sending notification to:", + entrepreneurId, + ); + await notification.save(); + + res.status(201).json(newRequest); + } catch (error) { + console.error("[DEBUG] POST /collaboration - ERROR:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +// Update request status +router.put("/:id/status", authenticateToken, async (req: AuthRequest, res) => { + try { + const { status } = req.body; + const { id } = req.params; + + const request = await CollaborationRequest.findOneAndUpdate( + { id }, + { $set: { status } }, + { new: true }, + ); + + if (!request) { + return res.status(404).json({ message: "Request not found" }); + } + + // Create notification for the other user + const targetUserId = + req.user?.role === "entrepreneur" + ? request.investorId + : request.entrepreneurId; + const notification = new Notification({ + id: `notif${Date.now()}`, + userId: targetUserId, + type: status === "accepted" ? "collaboration_accepted" : "meeting_status", // Reusing meeting_status for simplicity or we can add collaboration_rejected + title: `Collaboration Request ${status.charAt(0).toUpperCase() + status.slice(1)}`, + message: `Your collaboration request has been ${status}.`, + link: + req.user?.role === "entrepreneur" + ? "/dashboard/investor" + : "/dashboard/entrepreneur", + isRead: false, + }); + await notification.save(); + + res.json(request); + } catch (error) { + console.error("Update request error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +export default router; diff --git a/backend/routes/dashboard.routes.ts b/backend/routes/dashboard.routes.ts new file mode 100644 index 000000000..e7177d3bd --- /dev/null +++ b/backend/routes/dashboard.routes.ts @@ -0,0 +1,91 @@ +import express from "express"; +import { authenticateToken, AuthRequest } from "../middlewares/auth.middleware"; +import User from "../models/User.model"; +import Collaboration from "../models/CollaborationRequest.model"; +import Meeting, { IMeetingDocument } from "../models/Meeting.model"; +import Message from "../models/Message.model"; +const router = express.Router(); + +interface DashboardStats { + pendingRequests: number; + totalConnections: number; + upcomingMeetings: number; + unreadMessages: number; + recentActivity: unknown[]; + meetings?: IMeetingDocument[]; + profileViews?: number; + totalStartups?: number; +} + +// Get dashboard summary data +router.get("/summary", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const userRole = req.user?.role; + + if (!userId) { + return res.status(401).json({ message: "Unauthorized" }); + } + + // Basic stats + const stats: DashboardStats = { + pendingRequests: 0, + totalConnections: 0, + upcomingMeetings: 0, + unreadMessages: 0, + recentActivity: [], + }; + + // 1. Get Collaboration Stats + const collaborations = await Collaboration.find({ + $or: [{ investorId: userId }, { entrepreneurId: userId }], + }); + + stats.pendingRequests = collaborations.filter( + (c) => + c.status === "pending" && + (userRole === "entrepreneur" ? c.entrepreneurId === userId : true), + ).length; + stats.totalConnections = collaborations.filter( + (c) => c.status === "accepted", + ).length; + + // 2. Get Unread Messages + stats.unreadMessages = await Message.countDocuments({ + receiverId: userId, + isRead: false, + }); + + // 2. Get Upcoming Meetings + const now = new Date().toISOString(); + const meetings = await Meeting.find({ + $or: [{ investorId: userId }, { entrepreneurId: userId }], + startTime: { $gte: now }, + status: "accepted", + }) + .sort({ startTime: 1 }) + .limit(5); + + stats.upcomingMeetings = await Meeting.countDocuments({ + $or: [{ investorId: userId }, { entrepreneurId: userId }], + startTime: { $gte: now }, + status: "accepted", + }); + + stats.meetings = meetings; + + // 3. Role specific data + if (userRole === "entrepreneur") { + stats.profileViews = 24 + Math.floor(Math.random() * 10); + } else { + stats.totalStartups = await User.countDocuments({ role: "entrepreneur" }); + } + + res.json(stats); + } catch (error) { + console.error("Dashboard summary error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +export default router; diff --git a/backend/routes/deal.routes.ts b/backend/routes/deal.routes.ts new file mode 100644 index 000000000..08544c4fe --- /dev/null +++ b/backend/routes/deal.routes.ts @@ -0,0 +1,100 @@ +import express from "express"; +import { authenticateToken, AuthRequest } from "../middlewares/auth.middleware"; +import Deal from "../models/Deal.model"; + +const router = express.Router(); + +// Get all deals for current investor +router.get("/", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const deals = await Deal.find({ investorId: userId }).sort({ + lastActivity: -1, + }); + res.json(deals); + } catch (error) { + console.error("Fetch deals error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Create a new deal +router.post("/", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const { entrepreneurId, amount, equity, status, stage, notes } = req.body; + + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const newDeal = new Deal({ + id: `deal${Date.now()}`, + investorId: userId, + entrepreneurId, + amount, + equity, + status: status || "Due Diligence", + stage, + notes, + }); + + await newDeal.save(); + res.status(201).json(newDeal); + } catch (error) { + console.error("Create deal error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Update a deal +router.put("/:id", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const { id } = req.params; + const updates = req.body; + + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + // Update lastActivity timestamp + updates.lastActivity = new Date().toISOString(); + + const deal = await Deal.findOneAndUpdate( + { id, investorId: userId }, + { $set: updates }, + { new: true }, + ); + + if (!deal) { + return res.status(404).json({ message: "Deal not found" }); + } + + res.json(deal); + } catch (error) { + console.error("Update deal error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Delete a deal +router.delete("/:id", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const { id } = req.params; + + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const deal = await Deal.findOneAndDelete({ id, investorId: userId }); + + if (!deal) { + return res.status(404).json({ message: "Deal not found" }); + } + + res.json({ message: "Deal deleted" }); + } catch (error) { + console.error("Delete deal error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +export default router; diff --git a/backend/routes/directory.routes.ts b/backend/routes/directory.routes.ts new file mode 100644 index 000000000..62f31fb71 --- /dev/null +++ b/backend/routes/directory.routes.ts @@ -0,0 +1,31 @@ +import express from "express"; +import User from "../models/User.model"; +import { authenticateToken } from "../middlewares/auth.middleware"; + +const router = express.Router(); + +// Get investors +router.get("/investors", authenticateToken, async (req, res) => { + try { + const investors = await User.find({ role: "investor" }).select("-password"); + res.json(investors); + } catch (error) { + console.error("Fetch investors error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Get entrepreneurs +router.get("/entrepreneurs", authenticateToken, async (req, res) => { + try { + const entrepreneurs = await User.find({ role: "entrepreneur" }).select( + "-password", + ); + res.json(entrepreneurs); + } catch (error) { + console.error("Fetch entrepreneurs error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +export default router; diff --git a/backend/routes/document.routes.ts b/backend/routes/document.routes.ts new file mode 100644 index 000000000..f64ddfdcd --- /dev/null +++ b/backend/routes/document.routes.ts @@ -0,0 +1,91 @@ +import express from "express"; +import { authenticateToken, AuthRequest } from "../middlewares/auth.middleware"; +import Document from "../models/Document.model"; + +const router = express.Router(); + +// Get all documents for current user +router.get("/", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const documents = await Document.find({ userId }).sort({ createdAt: -1 }); + res.json(documents); + } catch (error) { + console.error("Fetch documents error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Upload a document +router.post("/", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const { name, type, size, content, shared } = req.body; + + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const newDocument = new Document({ + id: `doc${Date.now()}`, + userId, + name, + type, + size, + content, + shared: shared || false, + }); + + await newDocument.save(); + res.status(201).json(newDocument); + } catch (error) { + console.error("Upload document error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Delete a document +router.delete("/:id", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const { id } = req.params; + + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const document = await Document.findOneAndDelete({ id, userId }); + + if (!document) { + return res.status(404).json({ message: "Document not found" }); + } + + res.json({ message: "Document deleted" }); + } catch (error) { + console.error("Delete document error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Toggle document sharing +router.put("/:id/share", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const { id } = req.params; + + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const document = await Document.findOne({ id, userId }); + if (!document) { + return res.status(404).json({ message: "Document not found" }); + } + + document.shared = !document.shared; + await document.save(); + + res.json(document); + } catch (error) { + console.error("Share document error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +export default router; diff --git a/backend/routes/meeting.routes.ts b/backend/routes/meeting.routes.ts new file mode 100644 index 000000000..85fdb0288 --- /dev/null +++ b/backend/routes/meeting.routes.ts @@ -0,0 +1,177 @@ +import express from "express"; +import Meeting from "../models/Meeting.model"; +import Notification from "../models/Notification.model"; +import { authenticateToken, AuthRequest } from "../middlewares/auth.middleware"; + +const router = express.Router(); + +// Get all meetings for current user +router.get("/", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const meetings = await Meeting.find({ + $or: [{ investorId: userId }, { entrepreneurId: userId }], + }).sort({ startTime: 1 }); + + res.json(meetings); + } catch (error) { + console.error("Fetch meetings error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Schedule a meeting +router.post("/", authenticateToken, async (req: AuthRequest, res) => { + try { + const { + title, + description, + entrepreneurId, + investorId, + startTime, + endTime, + location, + } = req.body; + + // Validate that the request involves the current user + const currentUserId = req.user?.userId; + if (currentUserId !== entrepreneurId && currentUserId !== investorId) { + return res + .status(403) + .json({ message: "You can only schedule meetings for yourself" }); + } + + // Conflict detection for both parties + const conflict = await Meeting.findOne({ + $or: [ + { investorId }, + { entrepreneurId }, + { investorId: entrepreneurId }, // In case entrepreneur is also an investor in another context + { entrepreneurId: investorId }, // In case investor is also an entrepreneur in another context + ], + status: { $in: ["accepted", "pending"] }, + $and: [{ startTime: { $lt: endTime } }, { endTime: { $gt: startTime } }], + }); + + if (conflict) { + return res.status(409).json({ + message: + "Conflict detected. One of the participants already has a meeting or request during this time.", + conflict: { + title: conflict.title, + startTime: conflict.startTime, + endTime: conflict.endTime, + }, + }); + } + + const newMeeting = new Meeting({ + id: `m${Date.now()}`, + title, + description, + investorId, + entrepreneurId, + startTime, + endTime, + location, + status: "pending", + }); + + await newMeeting.save(); + + // Create notification for the other participant + const targetUserId = + currentUserId === entrepreneurId ? investorId : entrepreneurId; + const notification = new Notification({ + id: `notif${Date.now()}`, + userId: targetUserId, + type: "meeting_scheduled", + title: "New Meeting Scheduled", + message: `A new meeting "${title}" has been scheduled with you.`, + link: "/meetings", + isRead: false, + }); + await notification.save(); + + res.status(201).json(newMeeting); + } catch (error) { + console.error("Schedule meeting error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Update meeting status (accept/reject/cancel) +router.put("/:id/status", authenticateToken, async (req: AuthRequest, res) => { + try { + const { status } = req.body; + const userId = req.user?.userId; + + if (!["accepted", "rejected", "cancelled"].includes(status)) { + return res.status(400).json({ message: "Invalid status" }); + } + + const meeting = await Meeting.findOne({ id: req.params.id }); + if (!meeting) { + return res.status(404).json({ message: "Meeting not found" }); + } + + // Authorization checks + if (status === "cancelled") { + if (meeting.investorId !== userId && meeting.entrepreneurId !== userId) { + return res.status(403).json({ message: "Permission denied" }); + } + } else { + // Only the recipient of the invitation (or the other party) should ideally accept/reject + // But for simplicity, we allow either party to manage their status if they are involved + if (meeting.investorId !== userId && meeting.entrepreneurId !== userId) { + return res.status(403).json({ message: "Permission denied" }); + } + } + + meeting.status = status; + await meeting.save(); + + // Create notification for the other user + const targetUserId = + userId === meeting.investorId + ? meeting.entrepreneurId + : meeting.investorId; + const notification = new Notification({ + id: `notif${Date.now()}`, + userId: targetUserId, + type: "meeting_status", + title: `Meeting ${status.charAt(0).toUpperCase() + status.slice(1)}`, + message: `The status of your meeting "${meeting.title}" has been updated to ${status}.`, + link: "/meetings", + isRead: false, + }); + await notification.save(); + + res.json(meeting); + } catch (error) { + console.error("Update meeting status error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Get single meeting +router.get("/:id", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const meeting = await Meeting.findOne({ + id: req.params.id, + $or: [{ investorId: userId }, { entrepreneurId: userId }], + }); + + if (!meeting) { + return res.status(404).json({ message: "Meeting not found" }); + } + + res.json(meeting); + } catch (error) { + console.error("Fetch meeting error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +export default router; diff --git a/backend/routes/message.routes.ts b/backend/routes/message.routes.ts new file mode 100644 index 000000000..e85a936f9 --- /dev/null +++ b/backend/routes/message.routes.ts @@ -0,0 +1,148 @@ +import express from "express"; +import { authenticateToken, AuthRequest } from "../middlewares/auth.middleware"; +import Message from "../models/Message.model"; +import User from "../models/User.model"; +import Notification from "../models/Notification.model"; + +const router = express.Router(); + +// Get conversations for current user +router.get( + "/conversations", + authenticateToken, + async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + // Find all messages involving the user + const messages = await Message.find({ + $or: [{ senderId: userId }, { receiverId: userId }], + }).sort({ timestamp: -1 }); + + // Group by conversation partner + const conversationsMap = new Map(); + + for (const msg of messages) { + const partnerId = + msg.senderId === userId ? msg.receiverId : msg.senderId; + if (!conversationsMap.has(partnerId)) { + // Fetch partner info + const partner = await User.findOne({ id: partnerId }).select( + "name avatarUrl isOnline", + ); + if (partner) { + conversationsMap.set(partnerId, { + id: `conv-${userId}-${partnerId}`, + participants: [userId, partnerId], + partner: { + id: partnerId, + name: partner.name, + avatarUrl: partner.avatarUrl, + isOnline: partner.isOnline, + }, + lastMessage: msg, + updatedAt: msg.timestamp, + }); + } + } + } + + res.json(Array.from(conversationsMap.values())); + } catch (error) { + console.error("Fetch conversations error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +// Get total unread count +router.get( + "/unread-count", + authenticateToken, + async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const count = await Message.countDocuments({ + receiverId: userId, + isRead: false, + }); + + res.json({ count }); + } catch (error) { + console.error("Fetch unread count error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +// Get messages between two users +router.get("/:partnerId", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const partnerId = req.params.partnerId; + + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const messages = await Message.find({ + $or: [ + { senderId: userId, receiverId: partnerId }, + { senderId: partnerId, receiverId: userId }, + ], + }).sort({ timestamp: 1 }); + + // Mark as read + await Message.updateMany( + { senderId: partnerId, receiverId: userId, isRead: false }, + { $set: { isRead: true } }, + ); + + res.json(messages); + } catch (error) { + console.error("Fetch messages error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Send a message +router.post("/", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const { receiverId, content } = req.body; + + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const newMessage = new Message({ + id: `m${Date.now()}`, + senderId: userId, + receiverId, + content, + timestamp: new Date().toISOString(), + isRead: false, + }); + + await newMessage.save(); + + // Create notification for receiver + const sender = await User.findOne({ id: userId }).select("name"); + const notification = new Notification({ + id: `notif${Date.now()}`, + userId: receiverId, + type: "message", + title: "New Message", + message: `You have received a new message from ${sender?.name || "someone"}.`, + link: `/chat/${userId}`, + isRead: false, + }); + await notification.save(); + + res.status(201).json(newMessage); + } catch (error) { + console.error("Send message error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +export default router; diff --git a/backend/routes/notification.routes.ts b/backend/routes/notification.routes.ts new file mode 100644 index 000000000..60d019434 --- /dev/null +++ b/backend/routes/notification.routes.ts @@ -0,0 +1,87 @@ +import express from "express"; +import { authenticateToken, AuthRequest } from "../middlewares/auth.middleware"; +import Notification from "../models/Notification.model"; + +const router = express.Router(); + +// Get all notifications for current user +router.get("/", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const notifications = await Notification.find({ userId }).sort({ + createdAt: -1, + }); + res.json(notifications); + } catch (error) { + console.error("Fetch notifications error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Mark notification as read +router.put("/:id/read", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const { id } = req.params; + + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const notification = await Notification.findOneAndUpdate( + { id, userId }, + { $set: { isRead: true } }, + { new: true }, + ); + + if (!notification) { + return res.status(404).json({ message: "Notification not found" }); + } + + res.json(notification); + } catch (error) { + console.error("Update notification error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Mark all notifications as read +router.put("/read-all", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + await Notification.updateMany( + { userId, isRead: false }, + { $set: { isRead: true } }, + ); + + res.json({ message: "All notifications marked as read" }); + } catch (error) { + console.error("Read all notifications error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Delete a notification +router.delete("/:id", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const { id } = req.params; + + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const notification = await Notification.findOneAndDelete({ id, userId }); + + if (!notification) { + return res.status(404).json({ message: "Notification not found" }); + } + + res.json({ message: "Notification deleted" }); + } catch (error) { + console.error("Delete notification error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +export default router; diff --git a/backend/routes/payment.routes.ts b/backend/routes/payment.routes.ts new file mode 100644 index 000000000..590bacd06 --- /dev/null +++ b/backend/routes/payment.routes.ts @@ -0,0 +1,377 @@ +import express, { Response } from "express"; +import User from "../models/User.model"; +import Transaction from "../models/Transaction.model"; +import { authenticateToken, AuthRequest } from "../middlewares/auth.middleware"; +import { getSocketId } from "../socket.handler"; +import { + transactionSchema, + validateRequest, +} from "../middlewares/validation.middleware"; + +const router = express.Router(); + +/** + * @openapi + * tags: + * name: Payments + * description: Wallet and transaction management + */ + +/** + * @openapi + * /payments/balance: + * get: + * summary: Get user wallet balance + * tags: [Payments] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Wallet balance retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * balance: + * type: number + */ +router.get( + "/balance", + authenticateToken, + async (req: AuthRequest, res: Response) => { + try { + const user = await User.findOne({ id: req.user?.userId }); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + res.json({ balance: user.walletBalance || 0 }); + } catch (error) { + console.error("Fetch balance error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +/** + * @openapi + * /payments/transactions: + * get: + * summary: Get user transaction history + * tags: [Payments] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: List of transactions + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/Transaction' + */ +router.get( + "/transactions", + authenticateToken, + async (req: AuthRequest, res) => { + try { + const transactions = await Transaction.find({ + userId: req.user?.userId, + }).sort({ + createdAt: -1, + }); + res.json(transactions); + } catch (error) { + console.error("Fetch transactions error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +/** + * @openapi + * /payments/deposit: + * post: + * summary: Deposit funds into wallet + * tags: [Payments] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - amount + * properties: + * amount: + * type: number + * method: + * type: string + * responses: + * 200: + * description: Deposit successful + */ +router.post( + "/deposit", + authenticateToken, + transactionSchema, + validateRequest, + async (req: AuthRequest, res: Response) => { + try { + const { amount, method } = req.body; + + if (!amount || amount <= 0) { + return res.status(400).json({ message: "Invalid amount" }); + } + + const user = await User.findOne({ id: req.user?.userId }); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + // Update balance + user.walletBalance = (user.walletBalance || 0) + amount; + await user.save(); + + // Create transaction record + const transaction = new Transaction({ + id: `tx${Date.now()}`, + userId: user.id, + type: "deposit", + amount, + status: "completed", + description: `Deposit via ${method || "Card"}`, + }); + await transaction.save(); + + res.json({ + message: "Deposit successful", + balance: user.walletBalance, + transaction, + }); + } catch (error) { + console.error("Deposit error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +/** + * @openapi + * /payments/withdraw: + * post: + * summary: Withdraw funds from wallet + * tags: [Payments] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - amount + * properties: + * amount: + * type: number + * method: + * type: string + * responses: + * 200: + * description: Withdrawal successful + */ +router.post( + "/withdraw", + authenticateToken, + transactionSchema, + validateRequest, + async (req: AuthRequest, res: Response) => { + try { + const { amount, method } = req.body; + + if (!amount || amount <= 0) { + return res.status(400).json({ message: "Invalid amount" }); + } + + const user = await User.findOne({ id: req.user?.userId }); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + if (user.walletBalance < amount) { + return res.status(400).json({ message: "Insufficient funds" }); + } + + // Update balance + user.walletBalance -= amount; + await user.save(); + + // Create transaction record + const transaction = new Transaction({ + id: `tx${Date.now()}`, + userId: user.id, + type: "withdraw", + amount, + status: "completed", + description: `Withdrawal to ${method || "Bank Account"}`, + }); + await transaction.save(); + + res.json({ + message: "Withdrawal successful", + balance: user.walletBalance, + transaction, + }); + } catch (error) { + console.error("Withdrawal error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +/** + * @openapi + * /payments/transfer: + * post: + * summary: Transfer funds to another user + * tags: [Payments] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - recipientId + * - amount + * properties: + * recipientId: + * type: string + * amount: + * type: number + * description: + * type: string + * responses: + * 200: + * description: Transfer successful + */ +router.post( + "/transfer", + authenticateToken, + transactionSchema, + validateRequest, + async (req: AuthRequest, res: Response) => { + try { + const { recipientId, amount, description } = req.body; + const { userId: senderId } = req.user!; + + console.log("Transfer Start:", { senderId, recipientId, amount }); + + if (!amount || amount <= 0 || !recipientId) { + console.log("Transfer Validation Failed:", { recipientId, amount }); + return res.status(400).json({ message: "Invalid transfer details" }); + } + + if (recipientId === senderId) { + console.log("Self-transfer Attempt:", senderId); + return res.status(400).json({ message: "Cannot transfer to yourself" }); + } + + const sender = await User.findOne({ id: senderId }); + const recipient = await User.findOne({ id: recipientId }); + + if (!sender || !recipient) { + console.log("Users not found:", { + sender: !!sender, + recipient: !!recipient, + }); + return res.status(404).json({ message: "User not found" }); + } + + console.log("Balances Before:", { + sender: sender.walletBalance, + recipient: recipient.walletBalance, + }); + + if (sender.walletBalance < amount) { + console.log("Insufficient Funds:", { + balance: sender.walletBalance, + amount, + }); + return res.status(400).json({ message: "Insufficient funds" }); + } + + // Update balances + sender.walletBalance -= amount; + recipient.walletBalance = (recipient.walletBalance || 0) + amount; + + await sender.save(); + await recipient.save(); + + console.log("Balances After:", { + sender: sender.walletBalance, + recipient: recipient.walletBalance, + }); + + // Create transaction records for both + const senderTransaction = new Transaction({ + id: `tx${Date.now()}-S`, + userId: sender.id, + type: "transfer", + amount: -amount, + status: "completed", + description: description || `Transfer to ${recipient.name}`, + recipientId: recipient.id, + }); + + const recipientTransaction = new Transaction({ + id: `tx${Date.now()}-R`, + userId: recipient.id, + type: "transfer", + amount: amount, + status: "completed", + description: description || `Transfer from ${sender.name}`, + recipientId: sender.id, + }); + + await senderTransaction.save(); + await recipientTransaction.save(); + + console.log("Transactions Saved:", { + senderTx: senderTransaction.id, + recipientTx: recipientTransaction.id, + }); + + // Notify recipient via socket if online + const io = req.app.get("io"); + if (io) { + const recipientSocketId = getSocketId(recipient.id); + if (recipientSocketId) { + console.log("Emitting payment_received to:", recipientSocketId); + io.to(recipientSocketId).emit("payment_received", { + amount, + senderName: sender.name, + transaction: recipientTransaction.toObject(), + }); + } + } + + res.json({ + message: "Transfer successful", + balance: sender.walletBalance, + transaction: senderTransaction, + }); + } catch (error) { + console.error("Transfer error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +export default router; diff --git a/backend/routes/support.routes.ts b/backend/routes/support.routes.ts new file mode 100644 index 000000000..d2faeaafe --- /dev/null +++ b/backend/routes/support.routes.ts @@ -0,0 +1,87 @@ +import express from "express"; +import { authenticateToken, AuthRequest } from "../middlewares/auth.middleware"; +import SupportTicket from "../models/SupportTicket.model"; + +const router = express.Router(); + +// Get all tickets for current user +router.get("/tickets", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const tickets = await SupportTicket.find({ userId }).sort({ + createdAt: -1, + }); + res.json(tickets); + } catch (error) { + console.error("Fetch tickets error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Create a new support ticket +router.post("/tickets", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const { name, email, subject, message, priority } = req.body; + + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const newTicket = new SupportTicket({ + id: `ticket${Date.now()}`, + userId, + name, + email, + subject: subject || "Support Request", + message, + priority: priority || "medium", + status: "open", + }); + + await newTicket.save(); + res.status(201).json(newTicket); + } catch (error) { + console.error("Create ticket error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Update ticket status (could be used by admin in future) +router.put("/tickets/:id", authenticateToken, async (req: AuthRequest, res) => { + try { + const userId = req.user?.userId; + const { id } = req.params; + const { status, priority } = req.body; + + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const updates: Partial<{ + status: string; + priority: string; + updatedAt: string; + }> = { + updatedAt: new Date().toISOString(), + }; + + if (status) updates.status = status; + if (priority) updates.priority = priority; + + const ticket = await SupportTicket.findOneAndUpdate( + { id, userId }, + { $set: updates }, + { new: true }, + ); + + if (!ticket) { + return res.status(404).json({ message: "Ticket not found" }); + } + + res.json(ticket); + } catch (error) { + console.error("Update ticket error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +export default router; diff --git a/backend/routes/user.routes.ts b/backend/routes/user.routes.ts new file mode 100644 index 000000000..3963812d6 --- /dev/null +++ b/backend/routes/user.routes.ts @@ -0,0 +1,236 @@ +import express, { Response } from "express"; +import User from "../models/User.model"; +import CollaborationRequest from "../models/CollaborationRequest.model"; +import { authenticateToken, AuthRequest } from "../middlewares/auth.middleware"; +import { + profileUpdateSchema, + changePasswordSchema, + validateRequest, +} from "../middlewares/validation.middleware"; +import bcrypt from "bcryptjs"; + +const router = express.Router(); + +/** + * @openapi + * /users/profile: + * get: + * summary: Get current user profile + * tags: [Users] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: User profile details + * 401: + * description: Unauthorized + */ +// Get current user profile +router.get( + "/profile", + authenticateToken, + async (req: AuthRequest, res: Response) => { + try { + const user = await User.findOne({ id: req.user?.userId }).select( + "-password", + ); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + res.json(user); + } catch (error) { + console.error("Fetch profile error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +/** + * @openapi + * /users/search: + * get: + * summary: Search users by name, email, or ID + * tags: [Users] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: query + * required: true + * schema: + * type: string + * responses: + * 200: + * description: List of users matching query + */ +// Search users by name, email, or ID +router.get( + "/search", + authenticateToken, + async (req: AuthRequest, res: Response) => { + try { + const { query } = req.query; + const searchQuery = (query as string) || ""; + + // Build search criteria + const searchCriteria: Record = { + id: { $ne: req.user?.userId }, // Exclude current user + }; + + if (searchQuery.trim()) { + searchCriteria.$or = [ + { id: searchQuery }, + { name: { $regex: searchQuery, $options: "i" } }, + { email: { $regex: searchQuery, $options: "i" } }, + ]; + } + + const users = await User.find(searchCriteria) + .select("id name email role avatarUrl") + .limit(20); + + res.json(users); + } catch (error) { + console.error("User search error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +/** + * @openapi + * /users/connections: + * get: + * summary: Get connected users (accepted collaborations) + * tags: [Users] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: List of connected users + */ +// Get connected users (accepted collaborations) +router.get( + "/connections", + authenticateToken, + async (req: AuthRequest, res: Response) => { + try { + const { userId } = req.user!; + console.log("[DEBUG] /connections - fetching for user:", userId); + + const acceptedRequests = await CollaborationRequest.find({ + status: "accepted", + $or: [{ investorId: userId }, { entrepreneurId: userId }], + }); + + console.log( + "[DEBUG] /connections - accepted requests count:", + acceptedRequests.length, + ); + + const partnerIds = acceptedRequests.map((req) => + req.investorId === userId ? req.entrepreneurId : req.investorId, + ); + + console.log("[DEBUG] /connections - unique partner IDs:", [ + ...new Set(partnerIds), + ]); + + const connections = await User.find({ id: { $in: partnerIds } }) + .select("id name email role avatarUrl startupName industry") + .lean(); + + console.log("[DEBUG] /connections - users found:", connections.length); + + res.json(connections); + } catch (error) { + console.error("Fetch connections error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +// Get user profile by ID +router.get("/:id", authenticateToken, async (req, res) => { + try { + const user = await User.findOne({ id: req.params.id }).select("-password"); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + res.json(user); + } catch (error) { + console.error("Fetch profile by ID error:", error); + res.status(500).json({ message: "Server error" }); + } +}); + +// Update user profile +router.put( + "/profile", + authenticateToken, + profileUpdateSchema, + validateRequest, + async (req: AuthRequest, res: Response) => { + try { + const updates = req.body; + + // Prevent sensitive fields from being updated directly here + delete updates.password; + delete updates.email; + delete updates.id; + delete updates.role; + + const user = await User.findOneAndUpdate( + { id: req.user?.userId }, + { $set: updates }, + { new: true }, + ).select("-password"); + + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + res.json(user); + } catch (error) { + console.error("Update profile error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +// Change password +router.put( + "/change-password", + authenticateToken, + changePasswordSchema, + validateRequest, + async (req: AuthRequest, res: Response) => { + try { + const { currentPassword, newPassword } = req.body; + const userId = req.user?.userId; + + const user = await User.findOne({ id: userId }); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + // Verify current password + const isMatch = await bcrypt.compare(currentPassword, user.password); + if (!isMatch) { + return res.status(400).json({ message: "Incorrect current password" }); + } + + // Hash new password + const salt = await bcrypt.genSalt(10); + user.password = await bcrypt.hash(newPassword, salt); + await user.save(); + + res.json({ message: "Password updated successfully" }); + } catch (error) { + console.error("Change password error:", error); + res.status(500).json({ message: "Server error" }); + } + }, +); + +export default router; diff --git a/backend/server.ts b/backend/server.ts new file mode 100644 index 000000000..d1a43a6d6 --- /dev/null +++ b/backend/server.ts @@ -0,0 +1,88 @@ +import "dotenv/config"; +import express from "express"; +import cors from "cors"; +import mongoose from "mongoose"; +import { createServer } from "http"; +import { Server } from "socket.io"; +import swaggerUi from "swagger-ui-express"; +import { specs } from "./config/swaggerConfig"; +import { setupSocketHandlers } from "./socket.handler.js"; + +import authRoutes from "./routes/auth.routes"; +import userRoutes from "./routes/user.routes"; +import collaborationRoutes from "./routes/collaboration.routes"; +import directoryRoutes from "./routes/directory.routes"; +import meetingRoutes from "./routes/meeting.routes"; +import dashboardRoutes from "./routes/dashboard.routes"; +import messageRoutes from "./routes/message.routes"; +import notificationRoutes from "./routes/notification.routes"; +import documentRoutes from "./routes/document.routes"; +import dealRoutes from "./routes/deal.routes"; +import supportRoutes from "./routes/support.routes"; +import paymentRoutes from "./routes/payment.routes"; + +const app = express(); +const port = process.env.PORT || 3001; +const MONGO_URL = process.env.MONGODB_URL; + +// Middleware + +app.use( + cors({ + origin: process.env.CLIENT_URL, + credentials: true, + }), +); +app.use(express.json({ limit: "10mb" })); +app.use(express.urlencoded({ limit: "10mb", extended: true })); + +// MongoDB connection +if (!MONGO_URL) { + throw new Error("Please provide MONGODB_URL in the environment variables"); +} + +mongoose + .connect(MONGO_URL) + .then(() => console.log("Connected to MongoDB")) + .catch((err) => console.error("MongoDB connection error:", err)); + +// Routes +app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(specs)); +app.use("/api/auth", authRoutes); +app.use("/api/users", userRoutes); +app.use("/api/collaboration", collaborationRoutes); +app.use("/api/directory", directoryRoutes); +app.use("/api/meetings", meetingRoutes); +app.use("/api/dashboard", dashboardRoutes); +app.use("/api/messages", messageRoutes); +app.use("/api/notifications", notificationRoutes); +app.use("/api/documents", documentRoutes); +app.use("/api/deals", dealRoutes); +app.use("/api/support", supportRoutes); +app.use("/api/payments", paymentRoutes); + +app.get("/", (req, res) => { + res.send("API is running..."); +}); + +// Create HTTP server +const httpServer = createServer(app); + +// Initialize Socket.IO +const io = new Server(httpServer, { + cors: { + origin: process.env.CLIENT_URL, // Adjust this for production + methods: ["GET", "POST"], + credentials: true, + }, +}); + +app.set("io", io); + +// Setup socket handlers +setupSocketHandlers(io); + +// Start the server +httpServer.listen(port, () => { + console.log(`Server is running on port ${port}`); +}); diff --git a/backend/services/emailService.ts b/backend/services/emailService.ts new file mode 100644 index 000000000..9a2b81ea2 --- /dev/null +++ b/backend/services/emailService.ts @@ -0,0 +1,89 @@ +import nodemailer from "nodemailer"; + +// For development/mock purposes, we'll just log to console +// or use a test account if configured. +export const sendEmail = async (to: string, subject: string, html: string) => { + const isSmtpConfigured = + process.env.SMTP_HOST && + process.env.SMTP_PORT && + process.env.SMTP_USER && + process.env.SMTP_PASS; + + if (isSmtpConfigured) { + try { + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT || "587"), + secure: process.env.SMTP_PORT === "465", + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + + await transporter.sendMail({ + from: `"Business Nexus" <${process.env.SMTP_USER}>`, + to, + subject, + html, + }); + console.log(`[EMAIL SENT] To: ${to}`); + return true; + } catch (error) { + console.error("[EMAIL ERROR] SMTP failed, falling back to mock:", error); + } + } + + // Fallback to mock logging + console.log(`[MOCK EMAIL] To: ${to}, Subject: ${subject}`); + console.log(`[MOCK EMAIL] Body excerpt: ${html.substring(0, 100)}...`); + return true; +}; + +export const sendOTP = async (to: string, otp: string) => { + const subject = "Your Verification Code - Business Nexus"; + const html = ` +
+

Security Verification

+

Hello,

+

You have requested a verification code for Two-Factor Authentication.

+
+ ${otp} +
+

Use this code to complete your security setup. This code will expire in 10 minutes.

+

If you didn't request this code, please ignore this email or contact support if you have concerns.

+

+ © 2026 Business Nexus. All rights reserved. +

+
+ `; + + return sendEmail(to, subject, html); +}; + +export const sendResetPasswordEmail = async ( + to: string, + resetToken: string, +) => { + const resetLink = `http://localhost:5173/reset-password?token=${resetToken}`; + const subject = "Reset Your Password - Business Nexus"; + const html = ` +
+

Password Reset Request

+

Hello,

+

You have requested to reset your password for your Business Nexus account.

+ +

If the button above doesn't work, copy and paste the following link into your browser:

+

${resetLink}

+

This link will expire in 10 minutes.

+

If you didn't request a password reset, please ignore this email.

+

+ © 2026 Business Nexus. All rights reserved. +

+
+ `; + + return sendEmail(to, subject, html); +}; diff --git a/backend/socket.handler.ts b/backend/socket.handler.ts new file mode 100644 index 000000000..f99ac8d3a --- /dev/null +++ b/backend/socket.handler.ts @@ -0,0 +1,170 @@ +import { Server, Socket } from "socket.io"; + +interface SignalData { + roomId: string; + targetUserId?: string; + offer?: unknown; + answer?: unknown; + candidate?: unknown; +} + +const users = new Map(); // userId -> socketId +const recentlyActive = new Map(); // userId -> timeoutId +const lastSeen = new Map(); // userId -> ISO date string + +export const getSocketId = (userId: string) => users.get(userId); + +type UserStatus = "online" | "offline" | "recently_active"; + +interface StatusInfo { + status: UserStatus; + lastSeen: string; +} + +const broadcastStatuses = (io: Server) => { + const statuses: Record = {}; + + // Combine all known users + const allUserIds = new Set([ + ...users.keys(), + ...recentlyActive.keys(), + ...lastSeen.keys(), + ]); + + for (const userId of allUserIds) { + let status: UserStatus = "offline"; + if (users.has(userId)) { + status = "online"; + } else if (recentlyActive.has(userId)) { + status = "recently_active"; + } + + statuses[userId] = { + status, + lastSeen: lastSeen.get(userId) || new Date().toISOString(), + }; + } + + io.emit("user-statuses", statuses); +}; + +export const setupSocketHandlers = (io: Server) => { + console.log("Socket.IO handlers initialized"); + + io.on("connection", (socket: Socket) => { + console.log("User connected:", socket.id); + + socket.on("setup", (userId: string) => { + users.set(userId, socket.id); + lastSeen.set(userId, new Date().toISOString()); + + // If they were recently active, clear that timeout + if (recentlyActive.has(userId)) { + clearTimeout(recentlyActive.get(userId)); + recentlyActive.delete(userId); + } + + console.log(`User ${userId} setup with socket ${socket.id}`); + broadcastStatuses(io); + }); + + // Join a specific chat room + socket.on("join-room", (roomId: string) => { + socket.join(roomId); + console.log(`User ${socket.id} joined room: ${roomId}`); + socket.to(roomId).emit("user-joined", socket.id); + }); + + // WebRTC Signaling: Offer + socket.on("offer", ({ roomId, targetUserId, offer }: SignalData) => { + if (targetUserId) { + const targetSocketId = users.get(targetUserId); + if (targetSocketId) { + console.log( + `Sending offer from ${socket.id} directly to ${targetUserId}`, + ); + io.to(targetSocketId).emit("offer", { + from: socket.id, + offer, + roomId, + }); + } + } else { + console.log(`Sending offer from ${socket.id} to room ${roomId}`); + socket.to(roomId).emit("offer", { from: socket.id, offer }); + } + }); + + // WebRTC Signaling: Answer + socket.on("answer", ({ roomId, targetUserId, answer }: SignalData) => { + if (targetUserId) { + const targetSocketId = users.get(targetUserId); + if (targetSocketId) { + io.to(targetSocketId).emit("answer", { + from: socket.id, + answer, + roomId, + }); + } + } else { + socket.to(roomId).emit("answer", { from: socket.id, answer }); + } + }); + + // WebRTC Signaling: ICE Candidate + socket.on( + "ice-candidate", + ({ roomId, targetUserId, candidate }: SignalData) => { + if (targetUserId) { + const targetSocketId = users.get(targetUserId); + if (targetSocketId) { + io.to(targetSocketId).emit("ice-candidate", { + from: socket.id, + candidate, + roomId, + }); + } + } else { + socket + .to(roomId) + .emit("ice-candidate", { from: socket.id, candidate }); + } + }, + ); + + // Ending the call + socket.on("end-call", (roomId: string) => { + console.log(`Call ended in room ${roomId} by ${socket.id}`); + socket.to(roomId).emit("call-ended", { from: socket.id }); + }); + + socket.on("disconnect", () => { + console.log("User disconnected:", socket.id); + let disconnectedUserId: string | null = null; + + for (const [userId, socketId] of users.entries()) { + if (socketId === socket.id) { + disconnectedUserId = userId; + users.delete(userId); + break; + } + } + + if (disconnectedUserId) { + lastSeen.set(disconnectedUserId, new Date().toISOString()); + // Set to recently active for 5 minutes + const timeoutId = setTimeout( + () => { + recentlyActive.delete(disconnectedUserId!); + broadcastStatuses(io); + }, + 5 * 60 * 1000, + ); // 5 minutes + + recentlyActive.set(disconnectedUserId, timeoutId); + } + + broadcastStatuses(io); + }); + }); +}; diff --git a/debug_db.ts b/debug_db.ts new file mode 100644 index 000000000..3d7c59bd0 --- /dev/null +++ b/debug_db.ts @@ -0,0 +1,14 @@ +import mongoose from "mongoose"; +import "dotenv/config"; + +const mongoUri = process.env.MONGODB_URL; + +async function debug() { + await mongoose.connect(mongoUri!); + const User = mongoose.connection.collection("users"); + const user = await User.findOne({ id: "u1771080958847" }); + console.log("Saeed Email:", user?.email); + process.exit(0); +} + +debug(); diff --git a/gen_token.ts b/gen_token.ts new file mode 100644 index 000000000..b3701a4f1 --- /dev/null +++ b/gen_token.ts @@ -0,0 +1,10 @@ +import jwt from "jsonwebtoken"; +import "dotenv/config"; + +const JWT_SECRET = process.env.JWT_SECRET || "your_fallback_secret"; +const token = jwt.sign( + { userId: "u1771080958847", role: "investor" }, + JWT_SECRET, +); + +console.log(token); diff --git a/package-lock.json b/package-lock.json index bf00121ba..993a1fefa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,18 +9,39 @@ "version": "0.1.0", "dependencies": { "axios": "^1.6.7", + "bcryptjs": "^3.0.3", + "cors": "^2.8.6", "date-fns": "^3.3.1", + "dotenv": "^17.2.3", + "emoji-picker-react": "^4.18.0", + "express": "^5.2.1", + "express-validator": "^7.3.1", + "jsonwebtoken": "^9.0.3", "lucide-react": "^0.344.0", + "mongodb": "^7.0.0", + "mongoose": "^9.1.5", + "nodemailer": "^8.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", "react-hot-toast": "^2.4.1", - "react-router-dom": "^6.22.1" + "react-router-dom": "^6.22.1", + "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@eslint/js": "^9.9.1", + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/nodemailer": "^7.0.9", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.18", "eslint": "^9.9.1", @@ -29,9 +50,10 @@ "globals": "^15.9.0", "postcss": "^8.4.35", "tailwindcss": "^3.4.1", + "tsx": "^4.21.0", "typescript": "^5.5.3", "typescript-eslint": "^8.3.0", - "vite": "^5.4.2" + "vite": "^5.4.21" } }, "node_modules/@alloc/quick-lru": { @@ -59,6 +81,50 @@ "node": ">=6.0.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@babel/code-frame": { "version": "7.25.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", @@ -86,6 +152,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.7.tgz", "integrity": "sha512-yJ474Zv3cwiSOO9nXJuqzvwEeM+chDuQ8GJirw+pZ91sCGCyOZ3dJkVE09fTV0VEVzXyLWhh3G/AolYTPX7Mow==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.25.7", @@ -622,6 +689,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -638,6 +722,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -654,6 +755,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -955,6 +1073,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz", + "integrity": "sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1217,6 +1350,19 @@ "win32" ] }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "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/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1258,17 +1404,122 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, "node_modules/@types/prop-types": { "version": "15.7.13", @@ -1276,6 +1527,20 @@ "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", "dev": true }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "18.3.11", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", @@ -1295,6 +1560,60 @@ "@types/react": "*" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.8.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.1.tgz", @@ -1333,6 +1652,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.1.tgz", "integrity": "sha512-hQUVn2Lij2NAxVFEdvIGxT9gP1tq2yM83m+by3whWFsWC+1y8pxxxHUFE1UqDu2VsGi2i6RLcv4QvouM84U+ow==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.8.1", "@typescript-eslint/types": "8.8.1", @@ -1544,11 +1864,50 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1633,8 +1992,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/asynckit": { "version": "0.4.0", @@ -1702,8 +2060,25 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } }, "node_modules/binary-extensions": { "version": "2.3.0", @@ -1717,11 +2092,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1758,6 +2156,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001663", "electron-to-chromium": "^1.5.28", @@ -1771,6 +2170,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.1.1.tgz", + "integrity": "sha512-TtJgBB+QyOlWjrbM+8bRgH84VM/xrDjyBFgSgGrfZF4xvt6gbEDtcswm27Tn9F9TWsjQybxT8b8VpCP/oJK4Dw==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1784,6 +2207,28 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1803,9 +2248,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001667", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz", - "integrity": "sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw==", + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", "dev": true, "funding": [ { @@ -1820,7 +2265,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "2.4.2", @@ -1911,8 +2357,29 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -1920,18 +2387,53 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", "engines": { - "node": ">= 8" + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" } }, "node_modules/cssesc": { @@ -1949,7 +2451,8 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "peer": true }, "node_modules/date-fns": { "version": "3.6.0", @@ -1962,10 +2465,10 @@ } }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -1993,6 +2496,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2005,6 +2517,30 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "dev": true }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2025,18 +2561,121 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.33", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.33.tgz", "integrity": "sha512-+cYTcFB1QqD4j4LegwLfpCNxifb6dDFUAwk6RsLusCwIaZI6or2f+q8rs5tTB2YC53HhOlIbEaqHMAAC8IOIwA==", "dev": true }, + "node_modules/emoji-picker-react": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz", + "integrity": "sha512-vLTrLfApXAIciguGE57pXPWs9lPLBspbEpPMiUq03TIli2dHZBiB+aZ0R9/Wat0xmTfcd4AuEzQgSYxEZ8C88Q==", + "license": "MIT", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "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/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2129,6 +2768,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -2143,6 +2788,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.12.0.tgz", "integrity": "sha512-UVIOlTEWxwIopRL1wgSQYdnVDcEvs2wyaO6DGo5mXqe3r16IoCNWkR29iHhyaP4cICWjbgbmFUGAhh0GJRuGZw==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", @@ -2383,11 +3029,100 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-validator": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", + "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2479,6 +3214,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2495,6 +3251,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", + "license": "MIT" + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -2565,6 +3327,15 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -2578,6 +3349,21 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2646,6 +3432,19 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -2788,6 +3587,42 @@ "node": ">= 0.4" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2822,6 +3657,32 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2888,6 +3749,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2927,7 +3794,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -2977,6 +3843,70 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kareem": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.0.0.tgz", + "integrity": "sha512-RKhaOBSPN8L7y4yAgNhDT2602G5FD6QbOIISbjN9D6mjHPeqeg7K+EB5IGSU5o81/X2Gzm3ICnAvQW3x3OP8HA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3029,16 +3959,84 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -3072,6 +4070,33 @@ "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3119,7 +4144,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3136,11 +4160,108 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mongodb": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.0.0.tgz", + "integrity": "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.0.0", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.0.tgz", + "integrity": "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongoose": { + "version": "9.1.5", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.1.5.tgz", + "integrity": "sha512-N6gypEO+wLmZp8kCYNQmrEWxVMT0KhyHvVttBZoKA/1ngY7aUsBjqHzCPtDgz+i8JAnqMOiEKmuJIDEQu1b9Dw==", + "license": "MIT", + "dependencies": { + "kareem": "3.0.0", + "mongodb": "~7.0", + "mpath": "0.9.0", + "mquery": "6.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", + "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/mz": { "version": "2.7.0", @@ -3177,12 +4298,30 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-releases": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, + "node_modules/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3218,6 +4357,46 @@ "node": ">= 6" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3283,6 +4462,15 @@ "node": ">=6" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3292,6 +4480,15 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3329,6 +4526,16 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/picocolors": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", @@ -3384,6 +4591,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.0", @@ -3540,6 +4748,19 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3550,11 +4771,25 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "engines": { "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3575,10 +4810,35 @@ } ] }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3590,6 +4850,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3726,6 +4987,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -3771,6 +5042,22 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3794,6 +5081,32 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3811,6 +5124,82 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3832,39 +5221,213 @@ "node": ">=8" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, "engines": { - "node": ">=14" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "engines": { - "node": ">=0.10.0" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" }, "engines": { - "node": ">=12" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3992,130 +5555,699 @@ "has-flag": "^3.0.0" }, "engines": { - "node": ">=4" + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", + "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz", + "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/tailwindcss": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz", - "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==", + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.0", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "any-promise": "^1.0.0" + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.8" + "node": ">=18" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8.0" + "node": ">=18" } }, - "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">=16" + "node": ">=18" }, - "peerDependencies": { - "typescript": ">=4.2.0" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4128,11 +6260,51 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4164,6 +6336,21 @@ } } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", @@ -4209,11 +6396,31 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { - "version": "5.4.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", - "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, + "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -4268,6 +6475,28 @@ } } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4413,6 +6642,41 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4442,6 +6706,36 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } } } } diff --git a/package.json b/package.json index cdfd37b29..9fb091c06 100644 --- a/package.json +++ b/package.json @@ -7,22 +7,44 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "start": "tsx watch backend/server.ts" }, "dependencies": { + "axios": "^1.6.7", + "bcryptjs": "^3.0.3", + "cors": "^2.8.6", + "date-fns": "^3.3.1", + "dotenv": "^17.2.3", + "emoji-picker-react": "^4.18.0", + "express": "^5.2.1", + "express-validator": "^7.3.1", + "jsonwebtoken": "^9.0.3", "lucide-react": "^0.344.0", + "mongodb": "^7.0.0", + "mongoose": "^9.1.5", + "nodemailer": "^8.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.22.1", - "axios": "^1.6.7", - "date-fns": "^3.3.1", "react-dropzone": "^14.2.3", - "react-hot-toast": "^2.4.1" + "react-hot-toast": "^2.4.1", + "react-router-dom": "^6.22.1", + "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@eslint/js": "^9.9.1", + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/nodemailer": "^7.0.9", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.18", "eslint": "^9.9.1", @@ -31,8 +53,9 @@ "globals": "^15.9.0", "postcss": "^8.4.35", "tailwindcss": "^3.4.1", + "tsx": "^4.21.0", "typescript": "^5.5.3", "typescript-eslint": "^8.3.0", - "vite": "^5.4.2" + "vite": "^5.4.21" } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index 51b12d8bf..46c632c88 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,104 +1,134 @@ -import React from 'react'; -import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; -import { AuthProvider } from './context/AuthContext'; +import { + BrowserRouter as Router, + Routes, + Route, + Navigate, +} from "react-router-dom"; +import { AuthProvider } from "./context/AuthContext"; +import { SocketProvider } from "./context/SocketContext"; +import { Toaster } from "react-hot-toast"; // Layouts -import { DashboardLayout } from './components/layout/DashboardLayout'; +import { DashboardLayout } from "./components/layout/DashboardLayout"; // Auth Pages -import { LoginPage } from './pages/auth/LoginPage'; -import { RegisterPage } from './pages/auth/RegisterPage'; +import { LoginPage } from "./pages/auth/LoginPage"; +import { RegisterPage } from "./pages/auth/RegisterPage"; +import { ForgotPasswordPage } from "./pages/auth/ForgotPasswordPage"; +import { ResetPasswordPage } from "./pages/auth/ResetPasswordPage"; // Dashboard Pages -import { EntrepreneurDashboard } from './pages/dashboard/EntrepreneurDashboard'; -import { InvestorDashboard } from './pages/dashboard/InvestorDashboard'; +import { EntrepreneurDashboard } from "./pages/dashboard/EntrepreneurDashboard"; +import { InvestorDashboard } from "./pages/dashboard/InvestorDashboard"; // Profile Pages -import { EntrepreneurProfile } from './pages/profile/EntrepreneurProfile'; -import { InvestorProfile } from './pages/profile/InvestorProfile'; +import { EntrepreneurProfile } from "./pages/profile/EntrepreneurProfile"; +import { InvestorProfile } from "./pages/profile/InvestorProfile"; // Feature Pages -import { InvestorsPage } from './pages/investors/InvestorsPage'; -import { EntrepreneursPage } from './pages/entrepreneurs/EntrepreneursPage'; -import { MessagesPage } from './pages/messages/MessagesPage'; -import { NotificationsPage } from './pages/notifications/NotificationsPage'; -import { DocumentsPage } from './pages/documents/DocumentsPage'; -import { SettingsPage } from './pages/settings/SettingsPage'; -import { HelpPage } from './pages/help/HelpPage'; -import { DealsPage } from './pages/deals/DealsPage'; +import { InvestorsPage } from "./pages/investors/InvestorsPage"; +import { EntrepreneursPage } from "./pages/entrepreneurs/EntrepreneursPage"; +import { MessagesPage } from "./pages/messages/MessagesPage"; +import { NotificationsPage } from "./pages/notifications/NotificationsPage"; +import { DocumentsPage } from "./pages/documents/DocumentsPage"; +import { SettingsPage } from "./pages/settings/SettingsPage"; +import { HelpPage } from "./pages/help/HelpPage"; +import { DealsPage } from "./pages/deals/DealsPage"; +import { CollaborationPage } from "./pages/collaboration/CollaborationPage"; // Chat Pages -import { ChatPage } from './pages/chat/ChatPage'; +import { ChatPage } from "./pages/chat/ChatPage"; +import { MeetingsPage } from "./pages/meetings/MeetingsPage"; +import { TransactionsPage } from "./pages/dashboard/TransactionsPage"; function App() { return ( - - - {/* Authentication Routes */} - } /> - } /> - - {/* Dashboard Routes */} - }> - } /> - } /> - - - {/* Profile Routes */} - }> - } /> - } /> - - - {/* Feature Routes */} - }> - } /> - - - }> - } /> - - - }> - } /> - - - }> - } /> - - - }> - } /> - - - }> - } /> - - - }> - } /> - - - }> - } /> - - - {/* Chat Routes */} - }> - } /> - } /> - - - {/* Redirect root to login */} - } /> - - {/* Catch all other routes and redirect to login */} - } /> - - + + + + + {/* Authentication Routes */} + } /> + } /> + } /> + } /> + + {/* Dashboard Routes */} + }> + } /> + } /> + + + {/* Profile Routes */} + }> + } + /> + } /> + + + {/* Feature Routes */} + }> + } /> + + + }> + } /> + + + }> + } /> + + + }> + } /> + + + }> + } /> + + + }> + } /> + + + }> + } /> + + + }> + } /> + + + }> + } /> + + + }> + } /> + + + }> + } /> + } /> + + + }> + } /> + + + {/* Redirect root to login */} + } /> + + {/* Catch all other routes and redirect to login */} + } /> + + + ); } -export default App; \ No newline at end of file +export default App; diff --git a/src/components/chat/ChatMessage.tsx b/src/components/chat/ChatMessage.tsx index c3cf93ef4..264435a80 100644 --- a/src/components/chat/ChatMessage.tsx +++ b/src/components/chat/ChatMessage.tsx @@ -1,56 +1,64 @@ -import React from 'react'; -import { formatDistanceToNow } from 'date-fns'; -import { Message } from '../../types'; -import { Avatar } from '../ui/Avatar'; -import { findUserById } from '../../data/users'; - +import React from "react"; +import { formatDistanceToNow } from "date-fns"; +import { Message } from "../../types"; +import { Avatar } from "../ui/Avatar"; interface ChatMessageProps { message: Message; isCurrentUser: boolean; + sender: { + name: string; + avatarUrl: string; + }; } -export const ChatMessage: React.FC = ({ message, isCurrentUser }) => { - const user = findUserById(message.senderId); - - if (!user) return null; - +export const ChatMessage: React.FC = ({ + message, + isCurrentUser, + sender, +}) => { + if (!sender) return null; + return (
{!isCurrentUser && ( )} - -
+ +

{message.content}

- + - {formatDistanceToNow(new Date(message.timestamp), { addSuffix: true })} + {formatDistanceToNow(new Date(message.timestamp), { + addSuffix: true, + })}
- + {isCurrentUser && ( )}
); -}; \ No newline at end of file +}; diff --git a/src/components/chat/ChatUserList.tsx b/src/components/chat/ChatUserList.tsx index 62a295101..24a76dfff 100644 --- a/src/components/chat/ChatUserList.tsx +++ b/src/components/chat/ChatUserList.tsx @@ -1,23 +1,26 @@ -import React from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { formatDistanceToNow } from 'date-fns'; -import { ChatConversation } from '../../types'; -import { Avatar } from '../ui/Avatar'; -import { Badge } from '../ui/Badge'; -import { findUserById } from '../../data/users'; -import { useAuth } from '../../context/AuthContext'; +import React from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { formatDistanceToNow } from "date-fns"; +import { ChatConversation } from "../../types"; +import { Avatar } from "../ui/Avatar"; +import { Badge } from "../ui/Badge"; +import { useAuth } from "../../context/AuthContext"; +import { useSocket } from "../../context/SocketContext"; interface ChatUserListProps { conversations: ChatConversation[]; } -export const ChatUserList: React.FC = ({ conversations }) => { +export const ChatUserList: React.FC = ({ + conversations, +}) => { const navigate = useNavigate(); const { userId: activeUserId } = useParams<{ userId: string }>(); + const { userStatuses } = useSocket(); const { user: currentUser } = useAuth(); - + if (!currentUser) return null; - + const handleSelectUser = (userId: string) => { navigate(`/chat/${userId}`); }; @@ -25,28 +28,28 @@ export const ChatUserList: React.FC = ({ conversations }) => return (
-

Messages

- +

+ Messages +

+
{conversations.length > 0 ? ( - conversations.map(conversation => { - // Get the other participant (not the current user) - const otherParticipantId = conversation.participants.find(id => id !== currentUser.id); - if (!otherParticipantId) return null; - - const otherUser = findUserById(otherParticipantId); + conversations.map((conversation) => { + const otherUser = conversation.partner; if (!otherUser) return null; - + const lastMessage = conversation.lastMessage; - const isActive = activeUserId === otherParticipantId; - + const isActive = activeUserId === otherUser.id; + const statusInfo = userStatuses[otherUser.id]; + const status = statusInfo?.status || "offline"; + return (
handleSelectUser(otherUser.id)} > @@ -54,34 +57,43 @@ export const ChatUserList: React.FC = ({ conversations }) => src={otherUser.avatarUrl} alt={otherUser.name} size="md" - status={otherUser.isOnline ? 'online' : 'offline'} + status={status} className="mr-3 flex-shrink-0" /> - +

{otherUser.name}

- - {lastMessage && ( - - {formatDistanceToNow(new Date(lastMessage.timestamp), { addSuffix: false })} - - )} + + {/* Assuming ChatConversation now has an 'updatedAt' field */} + {/* The original snippet was syntactically incorrect, + this change assumes the intent was to use conversation.updatedAt */} + + {formatDistanceToNow(new Date(conversation.updatedAt), { + addSuffix: false, + })} +
- +
{lastMessage && (

- {lastMessage.senderId === currentUser.id ? 'You: ' : ''} + {lastMessage.senderId === currentUser.id + ? "You: " + : ""} {lastMessage.content}

)} - - {lastMessage && !lastMessage.isRead && lastMessage.senderId !== currentUser.id && ( - New - )} + + {lastMessage && + !lastMessage.isRead && + lastMessage.senderId !== currentUser.id && ( + + New + + )}
@@ -96,4 +108,4 @@ export const ChatUserList: React.FC = ({ conversations }) =>
); -}; \ No newline at end of file +}; diff --git a/src/components/chat/NewChatModal.tsx b/src/components/chat/NewChatModal.tsx new file mode 100644 index 000000000..26024bae6 --- /dev/null +++ b/src/components/chat/NewChatModal.tsx @@ -0,0 +1,103 @@ +import React, { useState, useEffect } from "react"; +import { X, Search, User as UserIcon } from "lucide-react"; +import { userService } from "../../services/userService"; +import { User } from "../../types"; +import { Avatar } from "../ui/Avatar"; + +interface NewChatModalProps { + onClose: () => void; + onSelectUser: (user: User) => void; +} + +export const NewChatModal: React.FC = ({ + onClose, + onSelectUser, +}) => { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + + useEffect(() => { + const fetchUsers = async () => { + setLoading(true); + try { + const data = await userService.searchUsers(searchQuery); + setUsers(data); + } catch (error) { + console.error("Failed to fetch users:", error); + } finally { + setLoading(false); + } + }; + + const timeoutId = setTimeout(fetchUsers, 300); // Debounce + return () => clearTimeout(timeoutId); + }, [searchQuery]); + + return ( +
+
+ {/* Header */} +
+

+ New Chat +

+ +
+ + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + autoFocus + /> +
+
+ + {/* User List */} +
+ {loading ? ( +
+
+
+ ) : users.length > 0 ? ( +
+ {users.map((user) => ( +
onSelectUser(user)} + className="flex items-center p-3 hover:bg-gray-50 rounded-lg cursor-pointer transition-colors" + > + +
+

+ {user.name} +

+

+ {user.role} +

+
+
+ ))} +
+ ) : ( +
+

No users found.

+
+ )} +
+
+
+ ); +}; diff --git a/src/components/chat/VideoCallModal.tsx b/src/components/chat/VideoCallModal.tsx new file mode 100644 index 000000000..8f3e131f3 --- /dev/null +++ b/src/components/chat/VideoCallModal.tsx @@ -0,0 +1,255 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + X, + Mic, + MicOff, + Video as VideoIcon, + VideoOff, + PhoneOff, +} from "lucide-react"; +import { useSocket } from "../../context/SocketContext"; +import { Button } from "../ui/Button"; +import { useCallback, useMemo } from "react"; + +interface VideoCallModalProps { + roomId: string; + targetUserId: string; + onClose: () => void; + isIncoming?: boolean; + callType?: "video" | "voice"; +} + +export const VideoCallModal: React.FC = ({ + roomId, + targetUserId, + onClose, + isIncoming = false, + callType = "video", +}) => { + const { socket } = useSocket(); + const [localStream, setLocalStream] = useState(null); + const [remoteStream, setRemoteStream] = useState(null); + const [isAudioMuted, setIsAudioMuted] = useState(false); + const [isVideoOff, setIsVideoOff] = useState(callType === "voice"); + const peerConnection = useRef(null); + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + + const servers = useMemo( + () => ({ + iceServers: [ + { + urls: [ + "stun:stun1.l.google.com:19302", + "stun:stun2.l.google.com:19302", + ], + }, + ], + }), + [], + ); + + const handleEndCall = useCallback(() => { + localStream?.getTracks().forEach((track) => track.stop()); + peerConnection.current?.close(); + socket?.emit("end-call", roomId); + onClose(); + }, [localStream, roomId, socket, onClose]); + + useEffect(() => { + const startCall = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: callType === "video", + audio: true, + }); + setLocalStream(stream); + if (localVideoRef.current) localVideoRef.current.srcObject = stream; + + peerConnection.current = new RTCPeerConnection(servers); + + stream.getTracks().forEach((track) => { + peerConnection.current?.addTrack(track, stream); + }); + + peerConnection.current.ontrack = (event) => { + setRemoteStream(event.streams[0]); + if (remoteVideoRef.current) + remoteVideoRef.current.srcObject = event.streams[0]; + }; + + peerConnection.current.onicecandidate = (event) => { + if (event.candidate) { + socket?.emit("ice-candidate", { + roomId, + targetUserId, + candidate: event.candidate, + }); + } + }; + + if (!isIncoming) { + const offer = await peerConnection.current.createOffer(); + await peerConnection.current.setLocalDescription(offer); + socket?.emit("offer", { roomId, targetUserId, offer }); + } + + socket?.on( + "offer", + async (data: { offer: RTCSessionDescriptionInit }) => { + const { offer } = data; + if (isIncoming && peerConnection.current) { + await peerConnection.current.setRemoteDescription( + new RTCSessionDescription(offer), + ); + const answer = await peerConnection.current.createAnswer(); + await peerConnection.current.setLocalDescription(answer); + socket.emit("answer", { roomId, targetUserId, answer }); + } + }, + ); + + socket?.on( + "answer", + async (data: { answer: RTCSessionDescriptionInit }) => { + const { answer } = data; + if (peerConnection.current) { + await peerConnection.current.setRemoteDescription( + new RTCSessionDescription(answer), + ); + } + }, + ); + + socket?.on( + "ice-candidate", + async (data: { candidate: RTCIceCandidateInit }) => { + const { candidate } = data; + if (peerConnection.current) { + await peerConnection.current.addIceCandidate( + new RTCIceCandidate(candidate), + ); + } + }, + ); + + socket?.on("call-ended", () => { + handleEndCall(); + }); + + socket?.emit("join-room", roomId); + } catch (err) { + console.error("Error accessing media devices:", err); + } + }; + + startCall(); + + return () => { + socket?.off("offer"); + socket?.off("answer"); + socket?.off("ice-candidate"); + socket?.off("call-ended"); + handleEndCall(); + }; + }, [ + roomId, + socket, + isIncoming, + handleEndCall, + servers, + targetUserId, + callType, + ]); + + const toggleAudio = () => { + if (localStream) { + localStream.getAudioTracks()[0].enabled = + !localStream.getAudioTracks()[0].enabled; + setIsAudioMuted(!isAudioMuted); + } + }; + + const toggleVideo = () => { + if (localStream) { + localStream.getVideoTracks()[0].enabled = + !localStream.getVideoTracks()[0].enabled; + setIsVideoOff(!isVideoOff); + } + }; + + return ( +
+
+ {/* Remote Video (Full Screen) */} +
+
+ + {/* Local Video (Floating) */} +
+
+ + {/* Controls */} +
+ + + + + +
+ + {/* Close Button */} + +
+
+ ); +}; diff --git a/src/components/collaboration/CollaborationRequestCard.tsx b/src/components/collaboration/CollaborationRequestCard.tsx index 192897711..094a148f1 100644 --- a/src/components/collaboration/CollaborationRequestCard.tsx +++ b/src/components/collaboration/CollaborationRequestCard.tsx @@ -1,64 +1,66 @@ -import React from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Check, X, MessageCircle } from 'lucide-react'; -import { CollaborationRequest } from '../../types'; -import { Card, CardBody, CardFooter } from '../ui/Card'; -import { Avatar } from '../ui/Avatar'; -import { Badge } from '../ui/Badge'; -import { Button } from '../ui/Button'; -import { findUserById } from '../../data/users'; -import { updateRequestStatus } from '../../data/collaborationRequests'; -import { formatDistanceToNow } from 'date-fns'; +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { Check, X, MessageCircle } from "lucide-react"; +import { CollaborationRequest } from "../../types"; +import { Card, CardBody, CardFooter } from "../ui/Card"; +import { Avatar } from "../ui/Avatar"; +import { Badge } from "../ui/Badge"; +import { Button } from "../ui/Button"; +import { formatDistanceToNow } from "date-fns"; interface CollaborationRequestCardProps { request: CollaborationRequest; - onStatusUpdate?: (requestId: string, status: 'accepted' | 'rejected') => void; + onStatusUpdate?: (requestId: string, status: "accepted" | "rejected") => void; } -export const CollaborationRequestCard: React.FC = ({ - request, - onStatusUpdate -}) => { +export const CollaborationRequestCard: React.FC< + CollaborationRequestCardProps +> = ({ request, onStatusUpdate }) => { const navigate = useNavigate(); - const investor = findUserById(request.investorId); - - if (!investor) return null; - + const investor = request.partner; // Support populated partner + + if (!investor) { + console.warn( + "CollaborationRequestCard: No partner data found for request", + request.id, + ); + return null; + } + const handleAccept = () => { - updateRequestStatus(request.id, 'accepted'); if (onStatusUpdate) { - onStatusUpdate(request.id, 'accepted'); + onStatusUpdate(request.id, "accepted"); } }; - + const handleReject = () => { - updateRequestStatus(request.id, 'rejected'); if (onStatusUpdate) { - onStatusUpdate(request.id, 'rejected'); + onStatusUpdate(request.id, "rejected"); } }; - + const handleMessage = () => { navigate(`/chat/${investor.id}`); }; - + const handleViewProfile = () => { - navigate(`/profile/investor/${investor.id}`); + const role = investor.role === "investor" ? "investor" : "entrepreneur"; + navigate(`/profile/${role}/${investor.id}`); }; - + const getStatusBadge = () => { switch (request.status) { - case 'pending': + case "pending": return Pending; - case 'accepted': + case "accepted": return Accepted; - case 'rejected': + case "rejected": return Declined; default: return null; } }; - + return ( @@ -68,28 +70,32 @@ export const CollaborationRequestCard: React.FC = src={investor.avatarUrl} alt={investor.name} size="md" - status={investor.isOnline ? 'online' : 'offline'} + status={investor.isOnline ? "online" : "offline"} className="mr-3" /> - +
-

{investor.name}

+

+ {investor.name} +

- {formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })} + {formatDistanceToNow(new Date(request.createdAt), { + addSuffix: true, + })}

- + {getStatusBadge()}
- +

{request.message}

- + - {request.status === 'pending' ? ( + {request.status === "pending" ? (
- + - -
@@ -142,4 +144,4 @@ export const CollaborationRequestCard: React.FC =
); -}; \ No newline at end of file +}; diff --git a/src/components/dashboard/PaymentModals.tsx b/src/components/dashboard/PaymentModals.tsx new file mode 100644 index 000000000..f7553d2f3 --- /dev/null +++ b/src/components/dashboard/PaymentModals.tsx @@ -0,0 +1,310 @@ +import React, { useState, useEffect } from "react"; +import { X, DollarSign } from "lucide-react"; +import { Button } from "../ui/Button"; +import { Input } from "../ui/Input"; +import paymentService from "../../services/paymentService"; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; +} + +const BaseModal: React.FC = ({ + isOpen, + onClose, + title, + children, +}) => { + if (!isOpen) return null; + + return ( +
+
+
+

{title}

+ +
+
{children}
+
+
+ ); +}; + +interface PaymentModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (amount: number, extra?: string) => Promise | void; + isLoading?: boolean; +} + +export const DepositModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + isLoading, +}) => { + const [amount, setAmount] = useState(""); + const [method, setMethod] = useState("Credit Card"); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onConfirm(Number(amount), method); + }; + + return ( + +
+ setAmount(e.target.value)} + required + startAdornment={} + fullWidth + /> +
+ + +
+
+ + +
+ +
+ ); +}; + +export const WithdrawModal: React.FC< + PaymentModalProps & { balance: number } +> = ({ isOpen, onClose, onConfirm, isLoading, balance }) => { + const [amount, setAmount] = useState(""); + const [method, setMethod] = useState("Bank Account"); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onConfirm(Number(amount), method); + }; + + return ( + +
+
+ Available Balance + + ${balance.toLocaleString()} + +
+ setAmount(e.target.value)} + required + startAdornment={} + fullWidth + error={Number(amount) > balance ? "Insufficient funds" : ""} + /> +
+ + +
+
+ + +
+ +
+ ); +}; + +export const TransferModal: React.FC< + PaymentModalProps & { balance: number } +> = ({ isOpen, onClose, onConfirm, isLoading, balance }) => { + const [amount, setAmount] = useState(""); + const [recipientId, setRecipientId] = useState(""); + const [description, setDescription] = useState(""); + const [connections, setConnections] = useState( + [], + ); + const [isFetchingConnections, setIsFetchingConnections] = useState(false); + + useEffect(() => { + if (isOpen) { + const fetchConnections = async () => { + setIsFetchingConnections(true); + try { + const data = await paymentService.getConnections(); + setConnections(data); + } catch (error) { + console.error("Failed to fetch connections:", error); + } finally { + setIsFetchingConnections(false); + } + }; + fetchConnections(); + } + }, [isOpen]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onConfirm(Number(amount), JSON.stringify({ recipientId, description })); + }; + + return ( + +
+
+ Available Balance + + ${balance.toLocaleString()} + +
+ +
+ + {isFetchingConnections ? ( +
+
+
+ ) : connections.length > 0 ? ( +
+ {connections.map((user) => ( + + ))} +
+ ) : ( +
+

+ No connected partners found. +

+

+ Establish collaborations to enable transfers. +

+
+ )} +
+ + setAmount(e.target.value)} + required + startAdornment={} + fullWidth + error={Number(amount) > balance ? "Insufficient funds" : ""} + /> + + setDescription(e.target.value)} + fullWidth + /> + +
+ + +
+ +
+ ); +}; diff --git a/src/components/dashboard/TransactionHistory.tsx b/src/components/dashboard/TransactionHistory.tsx new file mode 100644 index 000000000..a20fad857 --- /dev/null +++ b/src/components/dashboard/TransactionHistory.tsx @@ -0,0 +1,162 @@ +import React from "react"; +import { format } from "date-fns"; +import { + ArrowDownLeft, + ArrowUpRight, + ArrowRightLeft, + Clock, +} from "lucide-react"; +import { Transaction } from "../../types"; +import { Card, CardBody, CardHeader } from "../ui/Card"; +import { Badge } from "../ui/Badge"; + +interface TransactionHistoryProps { + transactions: Transaction[]; + loading?: boolean; +} + +export const TransactionHistory: React.FC = ({ + transactions, + loading = false, +}) => { + const getIcon = (type: string) => { + switch (type) { + case "deposit": + return ; + case "withdraw": + return ; + case "transfer": + return ; + default: + return ; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case "completed": + return "success"; + case "pending": + return "warning"; + case "failed": + return "error"; + default: + return "gray"; + } + }; + + return ( + + +
+

+ Recent Transactions +

+ {transactions.length} total +
+
+ + {loading ? ( +
+ Loading transactions... +
+ ) : transactions.length === 0 ? ( +
+ No transactions yet. +
+ ) : ( +
+ + + + + + + + + + + + {transactions.map((transaction) => { + const txKey = transaction.id || Math.random(); + return ( + + + + + + + + ); + })} + +
+ Type + + Description + + Date + + Amount + + Status +
+
+
+ {getIcon(transaction.type)} +
+ + {transaction.type} + +
+
+

+ {transaction.description} +

+
+

+ {format( + new Date(transaction.createdAt), + "MMM d, yyyy • HH:mm", + )} +

+
+ 0 + ? "text-green-600" + : "text-red-600" + }`} + > + {transaction.amount > 0 ? "+" : "-"} + {Math.abs(transaction.amount).toLocaleString( + undefined, + { + style: "currency", + currency: "USD", + }, + )} + + + + {transaction.status} + +
+
+ )} +
+
+ ); +}; diff --git a/src/components/dashboard/WalletCard.tsx b/src/components/dashboard/WalletCard.tsx new file mode 100644 index 000000000..5c785e0b4 --- /dev/null +++ b/src/components/dashboard/WalletCard.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { Wallet, Plus, ArrowUpRight, ArrowRight } from "lucide-react"; +import { Card, CardBody } from "../ui/Card"; +import { Button } from "../ui/Button"; + +interface WalletCardProps { + balance: number; + onDeposit: () => void; + onWithdraw: () => void; + onTransfer: () => void; +} + +export const WalletCard: React.FC = ({ + balance, + onDeposit, + onWithdraw, + onTransfer, +}) => { + return ( + +
+ +
+ +
+ +

Virtual Wallet

+
+ +
+

+ Total Balance +

+

+ $ + {balance.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+
+ +
+ + + +
+
+
+ ); +}; diff --git a/src/components/entrepreneur/EntrepreneurCard.tsx b/src/components/entrepreneur/EntrepreneurCard.tsx index 67f1fafe0..e9efafbf3 100644 --- a/src/components/entrepreneur/EntrepreneurCard.tsx +++ b/src/components/entrepreneur/EntrepreneurCard.tsx @@ -1,11 +1,12 @@ -import React from 'react'; -import { useNavigate } from 'react-router-dom'; -import { MessageCircle, ExternalLink } from 'lucide-react'; -import { Entrepreneur } from '../../types'; -import { Card, CardBody, CardFooter } from '../ui/Card'; -import { Avatar } from '../ui/Avatar'; -import { Badge } from '../ui/Badge'; -import { Button } from '../ui/Button'; +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { MessageCircle, ExternalLink } from "lucide-react"; +import { Entrepreneur } from "../../types"; +import { Card, CardBody, CardFooter } from "../ui/Card"; +import { Avatar } from "../ui/Avatar"; +import { Badge } from "../ui/Badge"; +import { Button } from "../ui/Button"; +import { useSocket } from "../../context/SocketContext"; interface EntrepreneurCardProps { entrepreneur: Entrepreneur; @@ -14,22 +15,23 @@ interface EntrepreneurCardProps { export const EntrepreneurCard: React.FC = ({ entrepreneur, - showActions = true + showActions = true, }) => { const navigate = useNavigate(); - + const { userStatuses } = useSocket(); + const handleViewProfile = () => { navigate(`/profile/entrepreneur/${entrepreneur.id}`); }; - + const handleMessage = (e: React.MouseEvent) => { e.stopPropagation(); // Prevent card click navigate(`/chat/${entrepreneur.id}`); }; - + return ( - @@ -39,40 +41,58 @@ export const EntrepreneurCard: React.FC = ({ src={entrepreneur.avatarUrl} alt={entrepreneur.name} size="lg" - status={entrepreneur.isOnline ? 'online' : 'offline'} + status={userStatuses[entrepreneur.id]?.status || "offline"} className="mr-4" /> - +
-

{entrepreneur.name}

-

{entrepreneur.startupName}

- +

+ {entrepreneur.name} +

+

+ {entrepreneur.startupName} +

+
- {entrepreneur.industry} - {entrepreneur.location} - Founded {entrepreneur.foundedYear} + + {entrepreneur.industry} + + + {entrepreneur.location} + + + Founded {entrepreneur.foundedYear} +
- +
-

Pitch Summary

-

{entrepreneur.pitchSummary}

+

+ Pitch Summary +

+

+ {entrepreneur.pitchSummary} +

- +
Funding Need -

{entrepreneur.fundingNeeded}

+

+ {entrepreneur.fundingNeeded} +

- +
Team Size -

{entrepreneur.teamSize} people

+

+ {entrepreneur.teamSize} people +

- + {showActions && ( - + - + - - + + - {user.name} + + {user.name} + ) : ( @@ -113,7 +154,7 @@ export const Navbar: React.FC = () => { )} - + {/* Mobile menu button */}
- + {/* Mobile menu */} {isMenuOpen && (
@@ -141,14 +182,18 @@ export const Navbar: React.FC = () => { src={user.avatarUrl} alt={user.name} size="sm" - status={user.isOnline ? 'online' : 'offline'} + status={userStatuses[user.id]?.status || "offline"} />
-

{user.name}

-

{user.role}

+

+ {user.name} +

+

+ {user.role} +

- +
{navLinks.map((link, index) => ( { {link.text} ))} - + + - setIsMenuOpen(false)} > @@ -197,4 +244,4 @@ export const Navbar: React.FC = () => { )} ); -}; \ No newline at end of file +}; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 842c11da0..0af55ee7a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,67 +1,166 @@ -import React from 'react'; -import { NavLink } from 'react-router-dom'; -import { useAuth } from '../../context/AuthContext'; -import { - Home, Building2, CircleDollarSign, Users, MessageCircle, - Bell, FileText, Settings, HelpCircle -} from 'lucide-react'; +import React from "react"; +import { NavLink } from "react-router-dom"; +import { useAuth } from "../../context/AuthContext"; +import { + Home, + Building2, + CircleDollarSign, + Users, + MessageCircle, + Bell, + FileText, + Settings, + HelpCircle, + CalendarDays, + CreditCard, +} from "lucide-react"; +import { notificationService } from "../../services/notificationService"; +import { messageService } from "../../services/messageService"; +import { Badge } from "../ui/Badge"; +import { useState, useEffect } from "react"; interface SidebarItemProps { to: string; icon: React.ReactNode; text: string; + badge?: number; } -const SidebarItem: React.FC = ({ to, icon, text }) => { +const SidebarItem: React.FC = ({ to, icon, text, badge }) => { return ( + className={({ isActive }) => `flex items-center py-2.5 px-4 rounded-md transition-colors duration-200 ${ - isActive - ? 'bg-primary-50 text-primary-700' - : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900' + isActive + ? "bg-primary-50 text-primary-700" + : "text-gray-600 hover:bg-gray-100 hover:text-gray-900" }` } > - {icon} - {text} +
+ {icon} + {text} +
+ {badge !== undefined && badge > 0 && ( + + {badge} + + )}
); }; export const Sidebar: React.FC = () => { const { user } = useAuth(); - + const [unreadMessages, setUnreadMessages] = useState(0); + const [unreadNotifications, setUnreadNotifications] = useState(0); + + useEffect(() => { + const fetchData = async () => { + if (user) { + try { + const [msgCount, notifData] = await Promise.all([ + messageService.getUnreadCount(), + notificationService.getNotifications(), + ]); + setUnreadMessages(msgCount); + setUnreadNotifications(notifData.filter((n) => !n.isRead).length); + } catch (error) { + console.error("Failed to fetch counts:", error); + } + } + }; + + fetchData(); + const interval = setInterval(fetchData, 10000); + return () => clearInterval(interval); + }, [user]); + if (!user) return null; - + // Define sidebar items based on user role const entrepreneurItems = [ - { to: '/dashboard/entrepreneur', icon: , text: 'Dashboard' }, - { to: '/profile/entrepreneur/' + user.id, icon: , text: 'My Startup' }, - { to: '/investors', icon: , text: 'Find Investors' }, - { to: '/messages', icon: , text: 'Messages' }, - { to: '/notifications', icon: , text: 'Notifications' }, - { to: '/documents', icon: , text: 'Documents' }, + { + to: "/dashboard/entrepreneur", + icon: , + text: "Dashboard", + }, + { + to: "/profile/entrepreneur/" + user.id, + icon: , + text: "My Startup", + }, + { + to: "/investors", + icon: , + text: "Find Investors", + }, + { + to: "/messages", + icon: , + text: "Messages", + badge: unreadMessages, + }, + { to: "/meetings", icon: , text: "Schedule" }, + { + to: "/notifications", + icon: , + text: "Notifications", + badge: unreadNotifications, + }, + { to: "/requests", icon: , text: "Requests" }, + { to: "/documents", icon: , text: "Documents" }, + { + to: "/transactions", + icon: , + text: "Transactions", + }, ]; - + const investorItems = [ - { to: '/dashboard/investor', icon: , text: 'Dashboard' }, - { to: '/profile/investor/' + user.id, icon: , text: 'My Portfolio' }, - { to: '/entrepreneurs', icon: , text: 'Find Startups' }, - { to: '/messages', icon: , text: 'Messages' }, - { to: '/notifications', icon: , text: 'Notifications' }, - { to: '/deals', icon: , text: 'Deals' }, + { to: "/dashboard/investor", icon: , text: "Dashboard" }, + { + to: "/profile/investor/" + user.id, + icon: , + text: "My Portfolio", + }, + { + to: "/entrepreneurs", + icon: , + text: "Find Startups", + }, + { + to: "/messages", + icon: , + text: "Messages", + badge: unreadMessages, + }, + { to: "/meetings", icon: , text: "Schedule" }, + { + to: "/notifications", + icon: , + text: "Notifications", + badge: unreadNotifications, + }, + { to: "/requests", icon: , text: "Requests" }, + { to: "/deals", icon: , text: "Deals" }, + { + to: "/transactions", + icon: , + text: "Transactions", + }, ]; - - const sidebarItems = user.role === 'entrepreneur' ? entrepreneurItems : investorItems; - + + const sidebarItems = + user.role === "entrepreneur" ? entrepreneurItems : investorItems; + // Common items at the bottom const commonItems = [ - { to: '/settings', icon: , text: 'Settings' }, - { to: '/help', icon: , text: 'Help & Support' }, + { to: "/settings", icon: , text: "Settings" }, + { to: "/help", icon: , text: "Help & Support" }, ]; - + return (
@@ -73,10 +172,11 @@ export const Sidebar: React.FC = () => { to={item.to} icon={item.icon} text={item.text} + badge={(item as { badge?: number }).badge} /> ))}
- +

Settings @@ -93,13 +193,15 @@ export const Sidebar: React.FC = () => {

- + ); -}; \ No newline at end of file +}; diff --git a/src/components/meetings/MeetingCalendar.tsx b/src/components/meetings/MeetingCalendar.tsx new file mode 100644 index 000000000..29e239e91 --- /dev/null +++ b/src/components/meetings/MeetingCalendar.tsx @@ -0,0 +1,244 @@ +import React, { useState, useEffect } from "react"; +import { + format, + startOfMonth, + endOfMonth, + startOfWeek, + endOfWeek, + eachDayOfInterval, + isSameMonth, + isSameDay, + addMonths, + subMonths, + parseISO, + isToday, +} from "date-fns"; +import { + ChevronLeft, + ChevronRight, + Clock, + MapPin, + Calendar as CalendarIcon, +} from "lucide-react"; +import { Meeting } from "../../types"; +import meetingService from "../../services/meetingService"; +import { Badge } from "../ui/Badge"; +import { Card, CardBody } from "../ui/Card"; + +export const MeetingCalendar: React.FC = () => { + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [meetings, setMeetings] = useState([]); + const [selectedDate, setSelectedDate] = useState(new Date()); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchMeetings = async () => { + setIsLoading(true); + try { + const data = await meetingService.getMeetings(); + setMeetings(data); + } catch (error) { + console.error("Failed to fetch meetings:", error); + } finally { + setIsLoading(false); + } + }; + fetchMeetings(); + }, []); + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(monthStart); + const startDate = startOfWeek(monthStart); + const endDate = endOfWeek(monthEnd); + + const days = eachDayOfInterval({ + start: startDate, + end: endDate, + }); + + const nextMonth = () => setCurrentMonth(addMonths(currentMonth, 1)); + const prevMonth = () => setCurrentMonth(subMonths(currentMonth, 1)); + + const meetingsOnSelectedDate = meetings.filter( + (meeting) => + selectedDate && isSameDay(parseISO(meeting.startTime), selectedDate), + ); + + const getStatusVariant = (status: string) => { + switch (status) { + case "accepted": + return "success"; + case "rejected": + return "error"; + case "cancelled": + return "gray"; + case "pending": + return "warning"; + default: + return "primary"; + } + }; + + return ( +
+ {/* Calendar Grid */} + + +
+

+ {format(currentMonth, "MMMM yyyy")} +

+
+ + +
+
+ +
+ {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {days.map((day, idx) => { + const dayMeetings = meetings.filter((m) => + isSameDay(parseISO(m.startTime), day), + ); + const isCurrentMonth = isSameMonth(day, monthStart); + const isSelected = selectedDate && isSameDay(day, selectedDate); + const today = isToday(day); + + return ( +
setSelectedDate(day)} + className={`min-h-[100px] p-2 border-r border-b cursor-pointer transition-colors ${ + !isCurrentMonth ? "bg-gray-50" : "bg-white" + } ${isSelected ? " ring-2 ring-primary-500 ring-inset" : "hover:bg-blue-50"}`} + > +
+ + {format(day, "d")} + + {dayMeetings.length > 0 && ( + + )} +
+ +
+ {dayMeetings.slice(0, 2).map((m, i) => ( +
+ {m.title} +
+ ))} + {dayMeetings.length > 2 && ( +
+ + {dayMeetings.length - 2} more +
+ )} +
+
+ ); + })} +
+
+
+ + {/* Selected Date Summary */} +
+ + +

+ + {selectedDate + ? format(selectedDate, "MMMM d, yyyy") + : "Select a date"} +

+ + {isLoading ? ( +
+
+
+ ) : meetingsOnSelectedDate.length > 0 ? ( +
+ {meetingsOnSelectedDate.map((meeting) => ( +
+
+

+ {meeting.title} +

+ + {meeting.status} + +
+ +
+
+ + {format(parseISO(meeting.startTime), "h:mm a")} -{" "} + {format(parseISO(meeting.endTime), "h:mm a")} +
+
+ + {meeting.location} +
+
+
+ ))} +
+ ) : ( +
+

+ No meetings scheduled for this day +

+
+ )} +
+
+ +
+

+ Quick Tip +

+

+ Click on any date in the calendar to see the detailed schedule and + manage your appointments. +

+
+
+
+ ); +}; diff --git a/src/components/meetings/ScheduleMeetingModal.tsx b/src/components/meetings/ScheduleMeetingModal.tsx new file mode 100644 index 000000000..b2ea0b731 --- /dev/null +++ b/src/components/meetings/ScheduleMeetingModal.tsx @@ -0,0 +1,163 @@ +import React, { useState } from "react"; +import { Calendar, Clock, MapPin, X } from "lucide-react"; +import { Button } from "../ui/Button"; +import { Input } from "../ui/Input"; +import { Meeting } from "../../types"; +import meetingService from "../../services/meetingService"; +import toast from "react-hot-toast"; +import { format, addHours, parseISO } from "date-fns"; + +interface ScheduleMeetingModalProps { + isOpen: boolean; + onClose: () => void; + investorId: string; + entrepreneurId: string; + onSuccess?: (meeting: Meeting) => void; +} + +export const ScheduleMeetingModal: React.FC = ({ + isOpen, + onClose, + investorId, + entrepreneurId, + onSuccess, +}) => { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [date, setDate] = useState(format(new Date(), "yyyy-MM-dd")); + const [time, setTime] = useState(format(new Date(), "HH:mm")); + const [duration, setDuration] = useState("1"); // hours + const [location, setLocation] = useState("Google Meet"); + const [isLoading, setIsLoading] = useState(false); + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + const startTime = parseISO(`${date}T${time}`); + const endTime = addHours(startTime, parseFloat(duration)); + + const meeting = await meetingService.scheduleMeeting({ + title, + description, + investorId, + entrepreneurId, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + location, + }); + + toast.success("Meeting scheduled successfully!"); + if (onSuccess) onSuccess(meeting); + onClose(); + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } }; + const message = + err.response?.data?.message || "Failed to schedule meeting"; + toast.error(message); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

+ Schedule Meeting +

+ +
+ +
+ setTitle(e.target.value)} + placeholder="e.g., Pitch Deck Review" + required + fullWidth + /> + +
+ + +
+ +
+ setDate(e.target.value)} + required + fullWidth + startAdornment={} + /> + setTime(e.target.value)} + required + fullWidth + startAdornment={} + /> +
+ +
+
+ + +
+ setLocation(e.target.value)} + placeholder="e.g., Zoom, Office" + fullWidth + startAdornment={} + /> +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/src/components/ui/Avatar.tsx b/src/components/ui/Avatar.tsx index fb0ab2ec7..0e92db12a 100644 --- a/src/components/ui/Avatar.tsx +++ b/src/components/ui/Avatar.tsx @@ -1,45 +1,46 @@ -import React from 'react'; +import React from "react"; -export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; +export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; interface AvatarProps { src: string; alt: string; size?: AvatarSize; className?: string; - status?: 'online' | 'offline' | 'away' | 'busy'; + status?: "online" | "offline" | "away" | "busy" | "recently_active"; } export const Avatar: React.FC = ({ src, alt, - size = 'md', - className = '', + size = "md", + className = "", status, }) => { const sizeClasses = { - xs: 'h-6 w-6', - sm: 'h-8 w-8', - md: 'h-10 w-10', - lg: 'h-12 w-12', - xl: 'h-16 w-16', + xs: "h-6 w-6", + sm: "h-8 w-8", + md: "h-10 w-10", + lg: "h-12 w-12", + xl: "h-16 w-16", }; - + const statusColors = { - online: 'bg-success-500', - offline: 'bg-gray-400', - away: 'bg-warning-500', - busy: 'bg-error-500', + online: "bg-green-500", + offline: "bg-red-500", + recently_active: "bg-yellow-500", + away: "bg-warning-500", + busy: "bg-error-500", }; - + const statusSizes = { - xs: 'h-1.5 w-1.5', - sm: 'h-2 w-2', - md: 'h-2.5 w-2.5', - lg: 'h-3 w-3', - xl: 'h-4 w-4', + xs: "h-1.5 w-1.5", + sm: "h-2 w-2", + md: "h-2.5 w-2.5", + lg: "h-3 w-3", + xl: "h-4 w-4", }; - + return (
= ({ target.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(alt)}&background=random`; }} /> - + {status && ( - )}
); -}; \ No newline at end of file +}; diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index 8c84dc3ed..e95c0867d 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -1,46 +1,55 @@ -import React from 'react'; +import React from "react"; -export type BadgeVariant = 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'error' | 'gray'; -export type BadgeSize = 'sm' | 'md' | 'lg'; +export type BadgeVariant = + | "primary" + | "secondary" + | "accent" + | "success" + | "warning" + | "error" + | "gray"; +export type BadgeSize = "sm" | "md" | "lg"; -interface BadgeProps { +interface BadgeProps extends React.HTMLAttributes { children: React.ReactNode; variant?: BadgeVariant; - size?: BadgeSize; + size?: BadgeSize; // Reverted to original as the provided snippet was syntactically incorrect and semantically out of place for BadgeProps rounded?: boolean; className?: string; } export const Badge: React.FC = ({ children, - variant = 'primary', - size = 'md', + variant = "primary", + size = "md", rounded = false, - className = '', + className = "", + ...props }) => { const variantClasses = { - primary: 'bg-primary-100 text-primary-800', - secondary: 'bg-secondary-100 text-secondary-800', - accent: 'bg-accent-100 text-accent-800', - success: 'bg-success-50 text-success-700', - warning: 'bg-warning-50 text-warning-700', - error: 'bg-error-50 text-error-700', - gray: 'bg-gray-100 text-gray-800', + primary: "bg-primary-100 text-primary-800", + secondary: "bg-secondary-100 text-secondary-800", + accent: "bg-accent-100 text-accent-800", + success: "bg-success-50 text-success-700", + warning: "bg-warning-50 text-warning-700", + error: "bg-error-50 text-error-700", + gray: "bg-gray-100 text-gray-800", }; - + const sizeClasses = { - sm: 'text-xs px-2 py-0.5', - md: 'text-sm px-2.5 py-0.5', - lg: 'text-base px-3 py-1', + sm: "text-xs px-2 py-0.5", + md: "text-sm px-2.5 py-0.5", + lg: "text-base px-3 py-1", }; - - const roundedClass = rounded ? 'rounded-full' : 'rounded'; - + + const roundedClass = rounded ? "rounded-full" : "rounded"; + return ( {children} ); -}; \ No newline at end of file +}; diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index 418ba0756..073ef7947 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from "react"; interface CardProps { children: React.ReactNode; @@ -7,24 +7,26 @@ interface CardProps { hoverable?: boolean; } -export const Card: React.FC = ({ - children, - className = '', - onClick, - hoverable = false, -}) => { - const hoverableClass = hoverable ? 'transform hover:-translate-y-1 transition-transform duration-300 cursor-pointer' : ''; - const clickableClass = onClick ? 'cursor-pointer' : ''; - - return ( -
- {children} -
- ); -}; +export const Card = React.forwardRef( + ({ children, className = "", onClick, hoverable = false }, ref) => { + const hoverableClass = hoverable + ? "transform hover:-translate-y-1 transition-transform duration-300 cursor-pointer" + : ""; + const clickableClass = onClick ? "cursor-pointer" : ""; + + return ( +
+ {children} +
+ ); + }, +); + +Card.displayName = "Card"; interface CardHeaderProps { children: React.ReactNode; @@ -33,7 +35,7 @@ interface CardHeaderProps { export const CardHeader: React.FC = ({ children, - className = '', + className = "", }) => { return (
@@ -49,13 +51,9 @@ interface CardBodyProps { export const CardBody: React.FC = ({ children, - className = '', + className = "", }) => { - return ( -
- {children} -
- ); + return
{children}
; }; interface CardFooterProps { @@ -65,11 +63,11 @@ interface CardFooterProps { export const CardFooter: React.FC = ({ children, - className = '', + className = "", }) => { return (
{children}
); -}; \ No newline at end of file +}; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 5d07bcf0a..8a3370e9c 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,171 +1,353 @@ -import React, { createContext, useState, useContext, useEffect } from 'react'; -import { User, UserRole, AuthContextType } from '../types'; -import { users } from '../data/users'; -import toast from 'react-hot-toast'; +/* eslint-disable react-refresh/only-export-components */ +import React, { createContext, useState, useContext, useEffect } from "react"; +import { User, UserRole, AuthContextType } from "../types"; +import api from "../services/api"; +import toast from "react-hot-toast"; // Create Auth Context const AuthContext = createContext(undefined); // Local storage keys -const USER_STORAGE_KEY = 'business_nexus_user'; -const RESET_TOKEN_KEY = 'business_nexus_reset_token'; +const USER_STORAGE_KEY = "business_nexus_user"; +const TOKEN_STORAGE_KEY = "business_nexus_token"; // Auth Provider Component -export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); - // Check for stored user on initial load + // Check for stored user and token on initial load useEffect(() => { - const storedUser = localStorage.getItem(USER_STORAGE_KEY); - if (storedUser) { - setUser(JSON.parse(storedUser)); - } - setIsLoading(false); + const initAuth = async () => { + const storedUser = localStorage.getItem(USER_STORAGE_KEY); + const token = localStorage.getItem(TOKEN_STORAGE_KEY); + + console.log("Initializing auth from storage:", { + hasUser: !!storedUser, + hasToken: !!token, + }); + + if (storedUser && token) { + try { + const parsedUser = JSON.parse(storedUser); + setUser(parsedUser); + + // Verify session with backend and get most up-to-date profile + console.log("Verifying token with backend..."); + const res = await api.get("/users/profile"); + setUser(res.data); + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(res.data)); + console.log("Session restored successfully"); + } catch (error) { + console.error("Failed to restore auth session:", error); + logout(); + } + } + setIsLoading(false); + }; + + initAuth(); }, []); - // Mock login function - in a real app, this would make an API call - const login = async (email: string, password: string, role: UserRole): Promise => { + // Listen for storage changes in other tabs + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === TOKEN_STORAGE_KEY || e.key === USER_STORAGE_KEY) { + console.log( + "Storage change detected in another tab, syncing auth state...", + ); + const storedUser = localStorage.getItem(USER_STORAGE_KEY); + const token = localStorage.getItem(TOKEN_STORAGE_KEY); + + if (!token || !storedUser) { + // If token or user removed in another tab, logout here too + console.log("Auth cleared in another tab, logging out..."); + setUser(null); + } else { + try { + const parsedUser = JSON.parse(storedUser); + // Only update if it's actually different to avoid unnecessary re-renders + if (JSON.stringify(parsedUser) !== JSON.stringify(user)) { + console.log( + "User data changed in another tab, updating state...", + ); + setUser(parsedUser); + } + } catch (error) { + console.error("Failed to parse synced user data:", error); + } + } + } + }; + + window.addEventListener("storage", handleStorageChange); + return () => window.removeEventListener("storage", handleStorageChange); + }, [user]); + + // Login function + const login = async ( + email: string, + password: string, + role: UserRole, + ): Promise<{ requires2FA?: boolean; tempToken?: string } | void> => { setIsLoading(true); - + console.log("Attempting login for:", email, "Role:", role); try { - // Simulate API call delay - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Find user with matching email and role - const foundUser = users.find(u => u.email === email && u.role === role); - - if (foundUser) { - setUser(foundUser); - localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(foundUser)); - toast.success('Successfully logged in!'); - } else { - throw new Error('Invalid credentials or user not found'); + const response = await api.post("/auth/login", { email, password }); + console.log("Login success:", response.data); + const { token, user: userData, requires2FA, tempToken } = response.data; + + if (requires2FA) { + toast.success("Verification code required to continue"); + return { requires2FA, tempToken }; } - } catch (error) { - toast.error((error as Error).message); - throw error; + + if (userData.role !== role) { + console.warn( + "Role mismatch. User is:", + userData.role, + "expected:", + role, + ); + throw new Error( + `Account found but role is ${userData.role}, not ${role}`, + ); + } + + setUser(userData); + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(userData)); + localStorage.setItem(TOKEN_STORAGE_KEY, token); + + toast.success("Successfully logged in!"); + } catch (error: unknown) { + const err = error as { + response?: { + data?: { message?: string; errors?: Array<{ msg: string }> }; + }; + message?: string; + }; + + let message = + err.response?.data?.message || err.message || "Login failed"; + + if ( + err.response?.data?.errors && + Array.isArray(err.response.data.errors) + ) { + const detail = err.response.data.errors.map((e) => e.msg).join(". "); + message = `${message}: ${detail}`; + } + + console.error("Login failed:", message); + toast.error(message); + throw new Error(message); } finally { setIsLoading(false); } }; - // Mock register function - in a real app, this would make an API call - const register = async (name: string, email: string, password: string, role: UserRole): Promise => { + // Register function + const register = async ( + name: string, + email: string, + password: string, + role: UserRole, + ): Promise => { setIsLoading(true); - + console.log("Registering user:", email); try { - // Simulate API call delay - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Check if email already exists - if (users.some(u => u.email === email)) { - throw new Error('Email already in use'); - } - - // Create new user - const newUser: User = { - id: `${role[0]}${users.length + 1}`, - name, - email, - role, - avatarUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random`, - bio: '', - isOnline: true, - createdAt: new Date().toISOString() + await api.post("/auth/register", { name, email, password, role }); + console.log("Registration success, logging in..."); + await login(email, password, role); + } catch (error: unknown) { + const err = error as { + response?: { data?: { message?: string } }; + message?: string; }; - - // Add user to mock data - users.push(newUser); - - setUser(newUser); - localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(newUser)); - toast.success('Account created successfully!'); - } catch (error) { - toast.error((error as Error).message); - throw error; + const message = + err.response?.data?.message || err.message || "Registration failed"; + console.error("Registration failed:", message); + toast.error(message); + throw new Error(message); } finally { setIsLoading(false); } }; - // Mock forgot password function + // Forgot password function const forgotPassword = async (email: string): Promise => { try { - // Simulate API call delay - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Check if user exists - const user = users.find(u => u.email === email); - if (!user) { - throw new Error('No account found with this email'); + const response = await api.post("/auth/forgot-password", { email }); + if (response.data.resetToken) { + localStorage.setItem( + "business_nexus_reset_token", + response.data.resetToken, + ); + console.log("Demo Reset Token:", response.data.resetToken); } - - // Generate reset token (in a real app, this would be a secure token) - const resetToken = Math.random().toString(36).substring(2, 15); - localStorage.setItem(RESET_TOKEN_KEY, resetToken); - - // In a real app, this would send an email - toast.success('Password reset instructions sent to your email'); - } catch (error) { - toast.error((error as Error).message); - throw error; + toast.success("Password reset instructions sent to your email"); + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } }; + const message = + err.response?.data?.message || "Failed to send reset email"; + toast.error(message); + throw new Error(message); } }; - // Mock reset password function - const resetPassword = async (token: string, newPassword: string): Promise => { + // Reset password function + const resetPassword = async ( + token: string, + newPassword: string, + ): Promise => { try { - // Simulate API call delay - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Verify token - const storedToken = localStorage.getItem(RESET_TOKEN_KEY); - if (token !== storedToken) { - throw new Error('Invalid or expired reset token'); - } - - // In a real app, this would update the user's password in the database - localStorage.removeItem(RESET_TOKEN_KEY); - toast.success('Password reset successfully'); - } catch (error) { - toast.error((error as Error).message); - throw error; + await api.post("/auth/reset-password", { token, newPassword }); + toast.success("Password reset successfully"); + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } }; + const message = err.response?.data?.message || "Reset failed"; + toast.error(message); + throw new Error(message); } }; // Logout function const logout = (): void => { + console.log("Logging out..."); setUser(null); localStorage.removeItem(USER_STORAGE_KEY); - toast.success('Logged out successfully'); + localStorage.removeItem(TOKEN_STORAGE_KEY); + toast.success("Logged out successfully"); }; // Update user profile - const updateProfile = async (userId: string, updates: Partial): Promise => { + const updateProfile = async ( + _userId: string, + updates: Partial, + ): Promise => { try { - // Simulate API call delay - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Update user in mock data - const userIndex = users.findIndex(u => u.id === userId); - if (userIndex === -1) { - throw new Error('User not found'); - } - - const updatedUser = { ...users[userIndex], ...updates }; - users[userIndex] = updatedUser; - - // Update current user if it's the same user - if (user?.id === userId) { - setUser(updatedUser); - localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(updatedUser)); + const response = await api.put("/users/profile", updates); + const updatedUser = response.data; + setUser(updatedUser); + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(updatedUser)); + toast.success("Profile updated successfully"); + } catch (error: unknown) { + const err = error as { + response?: { data?: { message?: string } }; + message?: string; + }; + const message = + err.response?.data?.message || err.message || "Update failed"; + toast.error(message); + throw new Error(message); + } + }; + + // Change password function + const changePassword = async ( + currentPassword: string, + newPassword: string, + ): Promise => { + try { + await api.put("/users/change-password", { + currentPassword, + newPassword, + }); + toast.success("Password updated successfully"); + } catch (error: unknown) { + const err = error as { + response?: { + data?: { message?: string; errors?: Array<{ msg: string }> }; + }; + }; + + let message = err.response?.data?.message || "Failed to update password"; + + if ( + err.response?.data?.errors && + Array.isArray(err.response.data.errors) + ) { + const detail = err.response.data.errors.map((e) => e.msg).join(". "); + message = `${message}: ${detail}`; } - - toast.success('Profile updated successfully'); - } catch (error) { - toast.error((error as Error).message); - throw error; + + toast.error(message); + throw new Error(message); + } + }; + + // 2FA Setup + const setup2FA = async (): Promise => { + try { + await api.post("/auth/2fa/setup"); + toast.success("Verification code sent to your email"); + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } }; + const message = err.response?.data?.message || "Failed to send 2FA code"; + toast.error(message); + throw new Error(message); + } + }; + + // 2FA Enable + const enable2FA = async (otp: string): Promise => { + try { + await api.post("/auth/2fa/enable", { otp }); + const updatedUser = { ...user, isTwoFactorEnabled: true } as User; + setUser(updatedUser); + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(updatedUser)); + toast.success("Two-factor authentication enabled"); + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } }; + const message = err.response?.data?.message || "Failed to enable 2FA"; + toast.error(message); + throw new Error(message); + } + }; + + // 2FA Disable + const disable2FA = async (): Promise => { + try { + await api.post("/auth/2fa/disable"); + const updatedUser = { ...user, isTwoFactorEnabled: false } as User; + setUser(updatedUser); + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(updatedUser)); + toast.success("Two-factor authentication disabled"); + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } }; + const message = err.response?.data?.message || "Failed to disable 2FA"; + toast.error(message); + throw new Error(message); + } + }; + + // 2FA Validate Login + const validate2FALogin = async ( + tempToken: string, + otp: string, + ): Promise => { + setIsLoading(true); + try { + const response = await api.post("/auth/2fa/validate-login", { + tempToken, + otp, + }); + const { token, user: userData } = response.data; + + setUser(userData); + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(userData)); + localStorage.setItem(TOKEN_STORAGE_KEY, token); + toast.success("Successfully logged in!"); + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } }; + const message = + err.response?.data?.message || "Invalid verification code"; + toast.error(message); + throw new Error(message); + } finally { + setIsLoading(false); } }; @@ -177,18 +359,22 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children forgotPassword, resetPassword, updateProfile, + changePassword, + setup2FA, + enable2FA, + disable2FA, + validate2FALogin, isAuthenticated: !!user, - isLoading + isLoading, }; return {children}; }; -// Custom hook for using auth context -export const useAuth = (): AuthContextType => { +export function useAuth() { const context = useContext(AuthContext); if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider'); + throw new Error("useAuth must be used within an AuthProvider"); } return context; -}; \ No newline at end of file +} diff --git a/src/context/SocketContext.tsx b/src/context/SocketContext.tsx new file mode 100644 index 000000000..7867c57b2 --- /dev/null +++ b/src/context/SocketContext.tsx @@ -0,0 +1,98 @@ +/* eslint-disable react-refresh/only-export-components */ +import React, { createContext, useContext, useEffect, useState } from "react"; +import { io, type Socket } from "socket.io-client"; +import { useAuth } from "./AuthContext"; +import toast from "react-hot-toast"; + +interface SocketContextType { + socket: Socket | null; + onlineUsers: string[]; + userStatuses: Record< + string, + { status: "online" | "offline" | "recently_active"; lastSeen: string } + >; +} + +const SocketContext = createContext({ + socket: null, + onlineUsers: [], + userStatuses: {}, +}); + +export function useSocket() { + return useContext(SocketContext); +} + +export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [socket, setSocket] = useState(null); + const [onlineUsers, setOnlineUsers] = useState([]); + const [userStatuses, setUserStatuses] = useState< + Record< + string, + { status: "online" | "offline" | "recently_active"; lastSeen: string } + > + >({}); + const { user } = useAuth(); + + useEffect(() => { + if (user) { + const socketUrl = + import.meta.env.VITE_SOCKET_URL || "http://localhost:3001"; + const newSocket = io(socketUrl, { + transports: ["websocket"], + withCredentials: true, + }); + setSocket(newSocket); + + newSocket.on("get-online-users", (users: string[]) => { + setOnlineUsers(users); + }); + + newSocket.on( + "user-statuses", + ( + statuses: Record< + string, + { + status: "online" | "offline" | "recently_active"; + lastSeen: string; + } + >, + ) => { + setUserStatuses(statuses); + }, + ); + + newSocket.on( + "payment_received", + (data: { amount: number; senderName: string }) => { + toast.success( + `Received $${data.amount.toLocaleString()} from ${data.senderName}`, + { + duration: 5000, + icon: "💰", + }, + ); + // Dispatch custom event for dashboards to refresh data + window.dispatchEvent(new CustomEvent("payment-updated")); + }, + ); + + newSocket.emit("setup", user.id); + + return () => { + newSocket.close(); + }; + } else { + setSocket(null); + } + }, [user]); + + return ( + + {children} + + ); +}; diff --git a/src/pages/auth/ForgotPasswordPage.tsx b/src/pages/auth/ForgotPasswordPage.tsx index 14a53a209..128152978 100644 --- a/src/pages/auth/ForgotPasswordPage.tsx +++ b/src/pages/auth/ForgotPasswordPage.tsx @@ -1,31 +1,31 @@ -import React, { useState } from 'react'; -import { Link } from 'react-router-dom'; -import { Mail, ArrowLeft } from 'lucide-react'; -import { useAuth } from '../../context/AuthContext'; -import { Button } from '../../components/ui/Button'; -import { Input } from '../../components/ui/Input'; +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { Mail, ArrowLeft } from "lucide-react"; +import { useAuth } from "../../context/AuthContext"; +import { Button } from "../../components/ui/Button"; +import { Input } from "../../components/ui/Input"; export const ForgotPasswordPage: React.FC = () => { - const [email, setEmail] = useState(''); + const [email, setEmail] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false); - + const { forgotPassword } = useAuth(); - + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); - + try { await forgotPassword(email); setIsSubmitted(true); - } catch (error) { + } catch { // Error is handled by the AuthContext } finally { setIsLoading(false); } }; - + if (isSubmitted) { return (
@@ -39,13 +39,13 @@ export const ForgotPasswordPage: React.FC = () => { We've sent password reset instructions to {email}

- +

Didn't receive the email? Check your spam folder or try again.

- + - +
); } - + return (
@@ -79,10 +79,11 @@ export const ForgotPasswordPage: React.FC = () => { Forgot your password?

- Enter your email address and we'll send you instructions to reset your password. + Enter your email address and we'll send you instructions to reset + your password.

- +
{ fullWidth startAdornment={} /> - - - +
); -}; \ No newline at end of file +}; diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index bbd5b08a0..609d3ae5c 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -1,56 +1,115 @@ -import React, { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { User, CircleDollarSign, Building2, LogIn, AlertCircle } from 'lucide-react'; -import { useAuth } from '../../context/AuthContext'; -import { Button } from '../../components/ui/Button'; -import { Input } from '../../components/ui/Input'; -import { UserRole } from '../../types'; +import React, { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { + User, + CircleDollarSign, + Building2, + LogIn, + AlertCircle, +} from "lucide-react"; +import { useAuth } from "../../context/AuthContext"; +import { Button } from "../../components/ui/Button"; +import { Input } from "../../components/ui/Input"; +import { UserRole } from "../../types"; export const LoginPage: React.FC = () => { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [role, setRole] = useState('entrepreneur'); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState("entrepreneur"); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); - - const { login } = useAuth(); + const [requires2FA, setRequires2FA] = useState(false); + const [tempToken, setTempToken] = useState(""); + const [otpCode, setOtpCode] = useState(""); + + const { login, validate2FALogin } = useAuth(); const navigate = useNavigate(); - + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); setIsLoading(true); - + try { - await login(email, password, role); + const result = await login(email, password, role); + if (result && result.requires2FA) { + setRequires2FA(true); + setTempToken(result.tempToken || ""); + setIsLoading(false); + return; + } // Redirect based on user role - navigate(role === 'entrepreneur' ? '/dashboard/entrepreneur' : '/dashboard/investor'); + navigate( + role === "entrepreneur" + ? "/dashboard/entrepreneur" + : "/dashboard/investor", + ); + } catch (err) { + setError((err as Error).message); + setIsLoading(false); + } + }; + + const handle2FAVerify = async (e: React.FormEvent) => { + e.preventDefault(); + if (!otpCode) { + setError("Please enter the verification code"); + return; + } + setError(null); + setIsLoading(true); + try { + await validate2FALogin(tempToken, otpCode); + navigate( + role === "entrepreneur" + ? "/dashboard/entrepreneur" + : "/dashboard/investor", + ); } catch (err) { setError((err as Error).message); setIsLoading(false); } }; - + // For demo purposes, pre-filled credentials const fillDemoCredentials = (userRole: UserRole) => { - if (userRole === 'entrepreneur') { - setEmail('sarah@techwave.io'); - setPassword('password123'); + if (userRole === "entrepreneur") { + setEmail("sarah@techwave.io"); + setPassword("password123"); } else { - setEmail('michael@vcinnovate.com'); - setPassword('password123'); + setEmail("michael@vcinnovate.com"); + setPassword("password123"); } setRole(userRole); }; - + return (
- - - + + +
@@ -70,119 +129,178 @@ export const LoginPage: React.FC = () => { {error}
)} - - -
- -
- - - -
-
- - setEmail(e.target.value)} - required - fullWidth - startAdornment={} - /> - - setPassword(e.target.value)} - required - fullWidth - /> - -
-
- -
- - Forgot your password? - + setEmail(e.target.value)} + required + fullWidth + startAdornment={} + /> + + setPassword(e.target.value)} + required + fullWidth + /> + +
+
+ + +
+ +
+ + Forgot your password? + +
-
- - + + ) : ( +
- Sign in - -
- +
+
+ +
+

+ Two-Factor Authentication +

+

+ Please enter the 6-digit code sent to your email address to + continue. +

+
+ + setOtpCode(e.target.value)} + placeholder="000000" + required + fullWidth + className="text-center tracking-widest font-mono text-2xl" + maxLength={6} + autoFocus + /> + +
+ + +
+ + )} +
- Demo Accounts + + Demo Accounts +
- +
- +
- +
@@ -192,11 +310,14 @@ export const LoginPage: React.FC = () => { Or
- +

- Don't have an account?{' '} - + Don't have an account?{" "} + Sign up

@@ -206,4 +327,4 @@ export const LoginPage: React.FC = () => {
); -}; \ No newline at end of file +}; diff --git a/src/pages/auth/ResetPasswordPage.tsx b/src/pages/auth/ResetPasswordPage.tsx index ec64920fb..33c95cab6 100644 --- a/src/pages/auth/ResetPasswordPage.tsx +++ b/src/pages/auth/ResetPasswordPage.tsx @@ -1,43 +1,43 @@ -import React, { useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { Lock } from 'lucide-react'; -import { useAuth } from '../../context/AuthContext'; -import { Button } from '../../components/ui/Button'; -import { Input } from '../../components/ui/Input'; +import React, { useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { Lock } from "lucide-react"; +import { useAuth } from "../../context/AuthContext"; +import { Button } from "../../components/ui/Button"; +import { Input } from "../../components/ui/Input"; export const ResetPasswordPage: React.FC = () => { - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); const [searchParams] = useSearchParams(); const navigate = useNavigate(); - + const { resetPassword } = useAuth(); - const token = searchParams.get('token'); - + const token = searchParams.get("token"); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - + if (!token) { return; } - + if (password !== confirmPassword) { return; } - + setIsLoading(true); - + try { await resetPassword(token, password); - navigate('/login'); - } catch (error) { + navigate("/login"); + } catch { // Error is handled by the AuthContext } finally { setIsLoading(false); } }; - + if (!token) { return (
@@ -51,7 +51,7 @@ export const ResetPasswordPage: React.FC = () => {

@@ -60,7 +60,7 @@ export const ResetPasswordPage: React.FC = () => {
); } - + return (
@@ -73,7 +73,7 @@ export const ResetPasswordPage: React.FC = () => { Enter your new password below

- +
{ fullWidth startAdornment={} /> - + { required fullWidth startAdornment={} - error={password !== confirmPassword ? 'Passwords do not match' : undefined} + error={ + password !== confirmPassword + ? "Passwords do not match" + : undefined + } /> - - @@ -109,4 +109,4 @@ export const ResetPasswordPage: React.FC = () => {
); -}; \ No newline at end of file +}; diff --git a/src/pages/chat/ChatPage.tsx b/src/pages/chat/ChatPage.tsx index 10c55b077..ad6ba5372 100644 --- a/src/pages/chat/ChatPage.tsx +++ b/src/pages/chat/ChatPage.tsx @@ -1,196 +1,707 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { useParams } from 'react-router-dom'; -import { Send, Phone, Video, Info, Smile } from 'lucide-react'; -import { Avatar } from '../../components/ui/Avatar'; -import { Button } from '../../components/ui/Button'; -import { Input } from '../../components/ui/Input'; -import { ChatMessage } from '../../components/chat/ChatMessage'; -import { ChatUserList } from '../../components/chat/ChatUserList'; -import { useAuth } from '../../context/AuthContext'; -import { Message } from '../../types'; -import { findUserById } from '../../data/users'; -import { getMessagesBetweenUsers, sendMessage, getConversationsForUser } from '../../data/messages'; -import { MessageCircle } from 'lucide-react'; +import React, { useState, useEffect, useRef } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + Send, + Phone, + Video, + Smile, + Search, + Mic, + X, + Paperclip, + CheckCheck, +} from "lucide-react"; +import { NewChatModal } from "../../components/chat/NewChatModal"; +import EmojiPicker, { EmojiClickData, Theme } from "emoji-picker-react"; +import { Avatar } from "../../components/ui/Avatar"; +import { Button } from "../../components/ui/Button"; +import { useAuth } from "../../context/AuthContext"; +import { Message, ChatConversation, User } from "../../types"; +import { messageService } from "../../services/messageService"; +import api from "../../services/api"; +import { MessageCircle } from "lucide-react"; +import { VideoCallModal } from "../../components/chat/VideoCallModal"; +import { formatDistanceToNow, format } from "date-fns"; +import { useSocket } from "../../context/SocketContext"; export const ChatPage: React.FC = () => { const { userId } = useParams<{ userId: string }>(); + const navigate = useNavigate(); const { user: currentUser } = useAuth(); const [messages, setMessages] = useState([]); - const [newMessage, setNewMessage] = useState(''); - const [conversations, setConversations] = useState([]); + const [newMessage, setNewMessage] = useState(""); + const [conversations, setConversations] = useState([]); + const [chatPartner, setChatPartner] = useState(null); + console.log( + "ChatPage Render. Current User:", + currentUser?.id, + "UserId Param:", + userId, + ); + const [showVideoCall, setShowVideoCall] = useState(false); + const [incomingCall, setIncomingCall] = useState<{ + roomId: string; + fromName: string; + } | null>(null); + const [callType, setCallType] = useState<"video" | "voice">("video"); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [isRecording, setIsRecording] = useState(false); + const [showNewChat, setShowNewChat] = useState(false); + const [showChatSearch, setShowChatSearch] = useState(false); + const [chatSearchQuery, setChatSearchQuery] = useState(""); + const fileInputRef = useRef(null); + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + const messagesEndRef = useRef(null); - - const chatPartner = userId ? findUserById(userId) : null; - + const isAtBottom = useRef(true); + const emojiPickerRef = useRef(null); + const { socket, userStatuses } = useSocket(); + + // Close emoji picker when clicking outside useEffect(() => { - // Load conversations - if (currentUser) { - setConversations(getConversationsForUser(currentUser.id)); - } + const handleClickOutside = (event: MouseEvent) => { + if ( + emojiPickerRef.current && + !emojiPickerRef.current.contains(event.target as Node) + ) { + setShowEmojiPicker(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + useEffect(() => { + const fetchConversations = async () => { + if (currentUser) { + try { + const data = await messageService.getConversations(); + console.log("Fetched conversations:", data); + setConversations(data); + } catch (error) { + console.error("Failed to fetch conversations:", error); + } + } + }; + fetchConversations(); + const interval = setInterval(fetchConversations, 5000); // Poll for new conversations + return () => clearInterval(interval); }, [currentUser]); - + useEffect(() => { - // Load messages between users - if (currentUser && userId) { - setMessages(getMessagesBetweenUsers(currentUser.id, userId)); - } + const fetchMessages = async () => { + if (currentUser && userId) { + try { + const data = await messageService.getMessages(userId); + console.log("Fetched messages:", data); + setMessages(data); + } catch (error) { + console.error("Failed to fetch messages:", error); + } + } + }; + + const fetchPartner = async () => { + if (userId) { + try { + const res = await api.get(`/users/${userId}`); + setChatPartner(res.data); + } catch (error) { + console.error("Failed to fetch partner:", error); + } + } + }; + + fetchMessages(); + fetchPartner(); + + const interval = setInterval(fetchMessages, 3000); // Poll for new messages + return () => clearInterval(interval); }, [currentUser, userId]); - + useEffect(() => { - // Scroll to bottom of messages - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); - - const handleSendMessage = (e: React.FormEvent) => { + if (socket && currentUser) { + socket.on( + "offer", + (data: { from: string; offer: unknown; roomId: string }) => { + setIncomingCall({ + roomId: data.roomId, + fromName: chatPartner?.name || "Incoming Call", + }); + }, + ); + + return () => { + socket.off("offer"); + }; + } + }, [socket, currentUser, userId, chatPartner?.name]); + + const handleScroll = (e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + const isBottom = scrollHeight - scrollTop - clientHeight < 50; + isAtBottom.current = isBottom; + }; + + useEffect(() => { + // Scroll to bottom ONLY if it's a new message sent by the current user + // This prevents auto-scrolling on initial load or when reading history + const lastMessage = messages[messages.length - 1]; + if (lastMessage && lastMessage.senderId === currentUser?.id) { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [messages, currentUser]); + + const initiateVideoCall = () => { + if (userId && currentUser) { + setCallType("video"); + setShowVideoCall(true); + } + }; + + const initiateVoiceCall = () => { + if (userId && currentUser) { + setCallType("voice"); + setShowVideoCall(true); + } + }; + + const acceptCall = () => { + if (incomingCall) { + setShowVideoCall(true); + // Wait a bit for the modal to mount and register listeners + setIncomingCall(null); + } + }; + + const onEmojiClick = (emojiData: EmojiClickData) => { + setNewMessage((prev) => prev + emojiData.emoji); + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setNewMessage((prev) => + prev ? `${prev}\n[File: ${file.name}]` : `[File: ${file.name}]`, + ); + } + }; + + const handleMicClick = async () => { + if (isRecording) { + // Stop Recording + if ( + mediaRecorderRef.current && + mediaRecorderRef.current.state === "recording" + ) { + mediaRecorderRef.current.stop(); + } + setIsRecording(false); + } else { + // Start Recording + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + const mediaRecorder = new MediaRecorder(stream); + mediaRecorderRef.current = mediaRecorder; + audioChunksRef.current = []; + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunksRef.current.push(event.data); + } + }; + + mediaRecorder.onstop = () => { + // Create blob (unused for now until backend supports upload, but logic is ready) + // const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); + setNewMessage((prev) => + prev ? `${prev}\n[Voice Message]` : `[Voice Message]`, + ); + + // Stop all tracks + stream.getTracks().forEach((track) => track.stop()); + }; + + mediaRecorder.start(); + setIsRecording(true); + } catch (error) { + console.error("Error accessing microphone:", error); + alert( + "Could not access microphone. Please ensure permissions are granted.", + ); + } + } + }; + + const handleSendMessage = async (e: React.FormEvent) => { e.preventDefault(); - + if (!newMessage.trim() || !currentUser || !userId) return; - - const message = sendMessage({ - senderId: currentUser.id, - receiverId: userId, - content: newMessage - }); - - setMessages([...messages, message]); - setNewMessage(''); - - // Update conversations - setConversations(getConversationsForUser(currentUser.id)); + + try { + const message = await messageService.sendMessage(userId, newMessage); + setMessages([...messages, message]); + setNewMessage(""); + setShowEmojiPicker(false); + + // Refresh conversations + const data = await messageService.getConversations(); + setConversations(data); + } catch (error) { + console.error("Failed to send message:", error); + } }; - + if (!currentUser) return null; - + + const filteredConversations = conversations.filter((c) => + c.partner?.name.toLowerCase().includes(searchQuery.toLowerCase()), + ); + return ( -
- {/* Conversations sidebar */} -
- +
+ {/* Sidebar - WhatsApp Style */} +
+ {/* Sidebar Header */} +
+ +
+ + +
+
+ + {/* Search Bar */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + {/* Conversation List */} +
+ {conversations.length > 0 ? ( +
+ {filteredConversations.map((conversation) => ( +
+ conversation.partner?.id && + navigate(`/chat/${conversation.partner.id}`) + } + className={`flex flex-row items-center p-4 cursor-pointer transition-all duration-200 border-b border-gray-100 ${ + userId === conversation.partner?.id + ? "bg-blue-50/60 border-l-4 border-l-primary-600 shadow-sm" + : "border-l-4 border-l-transparent hover:bg-gray-50" + }`} + > +
+ + {/* Online Status Indicator */} + {userStatuses[conversation.partner?.id || ""]?.status === + "online" && ( + + )} +
+ +
+
+

+ {conversation.partner?.name || "Unknown User"} +

+ {conversation.updatedAt && + !isNaN(new Date(conversation.updatedAt).getTime()) && ( + + {format(new Date(conversation.updatedAt), "HH:mm")} + + )} +
+
+

0 + ? "font-semibold text-gray-800" + : "text-gray-500" + }`} + > + {conversation.lastMessage?.content || "No messages yet"} +

+ {conversation.unreadCount > 0 && ( + + {conversation.unreadCount} + + )} +
+
+
+ ))} +
+ ) : ( +
+

No conversations yet.

+

+ Search for users to start chatting. +

+
+ )} +
- - {/* Main chat area */} -
- {/* Chat header */} + + {/* Chat Area */} +
{chatPartner ? ( <> -
-
+ {/* Chat Header - Customize for Project Look */} +
+
- -
-

{chatPartner.name}

-

- {chatPartner.isOnline ? 'Online' : 'Last seen recently'} -

+
+ + {chatPartner.name} + + + {userStatuses[chatPartner.id]?.status === "online" && ( + + )} + {userStatuses[chatPartner.id]?.status === "online" + ? "Online" + : userStatuses[chatPartner.id]?.status === + "recently_active" + ? `Last seen ${ + userStatuses[chatPartner.id].lastSeen && + !isNaN( + new Date( + userStatuses[chatPartner.id].lastSeen, + ).getTime(), + ) + ? formatDistanceToNow( + new Date( + userStatuses[chatPartner.id].lastSeen, + ), + { addSuffix: true }, + ) + : "" + }` + : "Offline"} +
- -
- - + +
- + +
+ +
+ {showChatSearch ? ( +
+ + setChatSearchQuery(e.target.value)} + autoFocus + onBlur={() => + !chatSearchQuery && setShowChatSearch(false) + } + /> + +
+ ) : ( + + )} +
- - {/* Messages container */} -
- {messages.length > 0 ? ( -
- {messages.map(message => ( - + + {/* Messages Container */} +
+
+ + Messages are end-to-end encrypted. No one outside of this + chat, not even Business Nexus, can read or listen to them. + +
+ + {messages + .filter((msg) => + msg.content + .toLowerCase() + .includes(chatSearchQuery.toLowerCase()), + ) + .map((message) => { + const isCurrentUser = message.senderId === currentUser.id; + return ( +
- ))} -
-
- ) : ( -
-
- -
-

No messages yet

-

Send a message to start the conversation

+ className={`flex w-full ${isCurrentUser ? "justify-end" : "justify-start"} mb-1`} + > +
+ {/* Removed triangles for cleaner modern UI */} + +
+ {message.content} +
+ +
+ + {message.createdAt && + !isNaN(new Date(message.createdAt).getTime()) + ? format(new Date(message.createdAt), "HH:mm") + : ""} + + {isCurrentUser && ( + + )} +
+
+
+ ); + })} +
+
+ + {/* Input Area */} +
+ {showEmojiPicker && ( +
+
)} -
- - {/* Message input */} -
-
- - - setNewMessage(e.target.value)} - fullWidth - className="flex-1" - /> - - + -
+ + + +
+ +
+
+ setNewMessage(e.target.value)} + /> +
+
+ +
) : ( -
-
- + /* Empty State */ +
+
+ {/* Illustration Placeholder - could use an SVG or Image */} +
+ +
-

Select a conversation

-

- Choose a contact from the list to start chatting +

+ Business Nexus Web +

+

+ Send and receive messages without keeping your phone online. +
+ Use Business Nexus on up to 4 linked devices and 1 phone.

+
+ + End-to-end encrypted +
)}
+ + {/* Video Call Modal */} + {showVideoCall && userId && currentUser && ( + setShowVideoCall(false)} + isIncoming={!!incomingCall} + callType={callType} + /> + )} + + {/* Incoming Call Notification */} + {incomingCall && ( +
+ +
+

{incomingCall.fromName}

+

Video calling you...

+
+
+ + +
+
+ )} + + {showNewChat && ( + setShowNewChat(false)} + onSelectUser={(user) => { + setShowNewChat(false); + navigate(`/chat/${user.id}`); + }} + /> + )}
); -}; \ No newline at end of file +}; diff --git a/src/pages/collaboration/CollaborationPage.tsx b/src/pages/collaboration/CollaborationPage.tsx new file mode 100644 index 000000000..1ecb29836 --- /dev/null +++ b/src/pages/collaboration/CollaborationPage.tsx @@ -0,0 +1,277 @@ +import React, { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; +import { Send, CheckCircle, XCircle, Clock, User } from "lucide-react"; +import { Card, CardBody } from "../../components/ui/Card"; +import { Button } from "../../components/ui/Button"; +import { Badge } from "../../components/ui/Badge"; +import { Avatar } from "../../components/ui/Avatar"; +import { useAuth } from "../../context/AuthContext"; +import { CollaborationRequest, User as UserType } from "../../types"; +import api from "../../services/api"; +import { format, parseISO } from "date-fns"; +import toast from "react-hot-toast"; + +type FilterTab = "all" | "pending" | "accepted" | "rejected"; + +export const CollaborationPage: React.FC = () => { + const { user } = useAuth(); + const [requests, setRequests] = useState([]); + const [users, setUsers] = useState>({}); + const [activeFilter, setActiveFilter] = useState("all"); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchRequests = async () => { + if (user) { + try { + const res = await api.get("/collaboration"); + setRequests(res.data); + + // Fetch user details for all participants + const userIds = new Set(); + res.data.forEach((req: CollaborationRequest) => { + userIds.add(req.investorId); + userIds.add(req.entrepreneurId); + }); + + const userPromises = Array.from(userIds).map((id) => + api.get(`/users/${id}`), + ); + const userResponses = await Promise.all(userPromises); + + const usersMap: Record = {}; + userResponses.forEach((response) => { + usersMap[response.data.id] = response.data; + }); + setUsers(usersMap); + } catch (error) { + console.error("Failed to fetch requests:", error); + } finally { + setIsLoading(false); + } + } + }; + fetchRequests(); + }, [user]); + + const handleStatusUpdate = async ( + requestId: string, + status: "accepted" | "rejected", + ) => { + try { + await api.put(`/collaboration/${requestId}/status`, { status }); + setRequests((prev) => + prev.map((req) => (req.id === requestId ? { ...req, status } : req)), + ); + toast.success(`Request ${status}`); + } catch (error) { + console.error("Failed to update status:", error); + toast.error("Failed to update request"); + } + }; + + const filteredRequests = requests.filter((req) => { + if (activeFilter === "all") return true; + return req.status === activeFilter; + }); + + const isInvestor = user?.role === "investor"; + const getOtherUser = (req: CollaborationRequest) => { + const userId = isInvestor ? req.entrepreneurId : req.investorId; + return users[userId]; + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

+ {isInvestor + ? "Collaboration Requests Sent" + : "Collaboration Requests Received"} +

+

+ {isInvestor + ? "Track your collaboration requests to startups" + : "Manage incoming collaboration requests from investors"} +

+
+ + {/* Filter tabs */} + + +
+ + + + +
+
+
+ + {/* Requests list */} +
+ {filteredRequests.length > 0 ? ( + filteredRequests.map((request) => { + const otherUser = getOtherUser(request); + return ( + + +
+
+ +
+
+ + {otherUser?.name || "Unknown"} + + {request.status === "pending" && ( + + Pending + + )} + {request.status === "accepted" && ( + + Accepted + + )} + {request.status === "rejected" && ( + + Rejected + + )} +
+ + {!isInvestor && otherUser?.startupName && ( +

+ {otherUser.startupName} +

+ )} + {isInvestor && otherUser?.startupName && ( +

+ {otherUser.startupName} +

+ )} + +

+ {request.message} +

+ +

+ Sent{" "} + {format( + parseISO(request.createdAt), + "MMM d, yyyy • h:mm a", + )} +

+
+
+ + {!isInvestor && request.status === "pending" && ( +
+ + +
+ )} + + {isInvestor && request.status === "accepted" && ( + + + + )} +
+
+
+ ); + }) + ) : ( +
+
+ {isInvestor ? : } +
+

+ {activeFilter === "all" + ? "No requests yet" + : `No ${activeFilter} requests`} +

+

+ {isInvestor + ? "Start by browsing startups and sending collaboration requests." + : "Investors will appear here when they request to collaborate with you."} +

+ {isInvestor && ( + + + + )} +
+ )} +
+
+ ); +}; diff --git a/src/pages/dashboard/EntrepreneurDashboard.tsx b/src/pages/dashboard/EntrepreneurDashboard.tsx index 6c1dd3b17..de2d68e17 100644 --- a/src/pages/dashboard/EntrepreneurDashboard.tsx +++ b/src/pages/dashboard/EntrepreneurDashboard.tsx @@ -1,60 +1,210 @@ -import React, { useState, useEffect } from 'react'; -import { Link } from 'react-router-dom'; -import { Users, Bell, Calendar, TrendingUp, AlertCircle, PlusCircle } from 'lucide-react'; -import { Button } from '../../components/ui/Button'; -import { Card, CardBody, CardHeader } from '../../components/ui/Card'; -import { Badge } from '../../components/ui/Badge'; -import { CollaborationRequestCard } from '../../components/collaboration/CollaborationRequestCard'; -import { InvestorCard } from '../../components/investor/InvestorCard'; -import { useAuth } from '../../context/AuthContext'; -import { CollaborationRequest } from '../../types'; -import { getRequestsForEntrepreneur } from '../../data/collaborationRequests'; -import { investors } from '../../data/users'; +import React, { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; +import { + Users, + Bell, + Calendar, + MessageCircle, + AlertCircle, + PlusCircle, + Clock, + MapPin, + FileText, +} from "lucide-react"; +import { Button } from "../../components/ui/Button"; +import { Card, CardBody, CardHeader } from "../../components/ui/Card"; +import { Badge } from "../../components/ui/Badge"; +import { CollaborationRequestCard } from "../../components/collaboration/CollaborationRequestCard"; +import { InvestorCard } from "../../components/investor/InvestorCard"; +import { useAuth } from "../../context/AuthContext"; +import api from "../../services/api"; +import dashboardService, { + DashboardSummary, +} from "../../services/dashboardService"; +import documentService from "../../services/documentService"; +import { format, parseISO } from "date-fns"; +import { WalletCard } from "../../components/dashboard/WalletCard"; +import { TransactionHistory } from "../../components/dashboard/TransactionHistory"; +import { + DepositModal, + WithdrawModal, + TransferModal, +} from "../../components/dashboard/PaymentModals"; +import paymentService from "../../services/paymentService"; +import toast from "react-hot-toast"; +import { + CollaborationRequest, + Investor, + Meeting, + Document, + Transaction, +} from "../../types"; export const EntrepreneurDashboard: React.FC = () => { const { user } = useAuth(); - const [collaborationRequests, setCollaborationRequests] = useState([]); - const [recommendedInvestors, setRecommendedInvestors] = useState(investors.slice(0, 3)); - + const [summary, setSummary] = useState(null); + const [collaborationRequests, setCollaborationRequests] = useState< + CollaborationRequest[] + >([]); + const [recommendedInvestors, setRecommendedInvestors] = useState( + [], + ); + const [recentDocuments, setRecentDocuments] = useState([]); + const [transactions, setTransactions] = useState([]); + const [balance, setBalance] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [isPaymentLoading, setIsPaymentLoading] = useState(false); + + // Modal states + const [isDepositOpen, setIsDepositOpen] = useState(false); + const [isWithdrawOpen, setIsWithdrawOpen] = useState(false); + const [isTransferOpen, setIsTransferOpen] = useState(false); + useEffect(() => { - if (user) { - // Load collaboration requests - const requests = getRequestsForEntrepreneur(user.id); - setCollaborationRequests(requests); - } + const fetchDashboardData = async () => { + if (user) { + setIsLoading(true); + try { + // Fetch summary stats + const summaryData = await dashboardService.getSummary(); + setSummary(summaryData); + + // Fetch collaboration requests from API + const requestsRes = await api.get("/collaboration"); + setCollaborationRequests(requestsRes.data); + + // Fetch investors for recommendation from API + const investorsRes = await api.get("/directory/investors"); + setRecommendedInvestors(investorsRes.data.slice(0, 3)); + + // Fetch recent documents + const docsRes = await documentService.getDocuments(); + setRecentDocuments(docsRes.slice(0, 4)); + + // Fetch wallet data + const [balanceData, transactionsData] = await Promise.all([ + paymentService.getBalance(), + paymentService.getTransactions(), + ]); + setBalance(balanceData); + setTransactions(transactionsData); + } catch (error) { + console.error("Failed to fetch dashboard data:", error); + } finally { + setIsLoading(false); + } + } + }; + + fetchDashboardData(); + window.addEventListener("payment-updated", fetchDashboardData); + return () => + window.removeEventListener("payment-updated", fetchDashboardData); }, [user]); - - const handleRequestStatusUpdate = (requestId: string, status: 'accepted' | 'rejected') => { - setCollaborationRequests(prevRequests => - prevRequests.map(req => - req.id === requestId ? { ...req, status } : req - ) - ); + + const handleDeposit = async (amount: number, method?: string) => { + setIsPaymentLoading(true); + try { + const res = await paymentService.deposit(amount, method); + setBalance(res.balance); + setTransactions([res.transaction, ...transactions]); + toast.success(res.message); + setIsDepositOpen(false); + } catch { + toast.error("Deposit failed"); + } finally { + setIsPaymentLoading(false); + } + }; + + const handleWithdraw = async (amount: number, method?: string) => { + setIsPaymentLoading(true); + try { + const res = await paymentService.withdraw(amount, method); + setBalance(res.balance); + setTransactions([res.transaction, ...transactions]); + toast.success(res.message); + setIsWithdrawOpen(false); + } catch { + toast.error("Withdrawal failed"); + } finally { + setIsPaymentLoading(false); + } }; - + + const handleTransfer = async (amount: number, data?: string) => { + try { + if (!data) return; + setIsPaymentLoading(true); + const { recipientId, description } = JSON.parse(data); + const res = await paymentService.transfer( + recipientId, + amount, + description, + ); + setBalance(res.balance); + setTransactions([res.transaction, ...transactions]); + toast.success(res.message); + setIsTransferOpen(false); + } catch { + toast.error("Transfer failed"); + } finally { + setIsPaymentLoading(false); + } + }; + + const handleRequestStatusUpdate = async ( + requestId: string, + status: "accepted" | "rejected", + ) => { + try { + await api.put(`/collaboration/${requestId}/status`, { status }); + setCollaborationRequests((prevRequests) => + prevRequests.map((req) => + req.id === requestId ? { ...req, status } : req, + ), + ); + // Refresh summary to update counts + const summaryData = await dashboardService.getSummary(); + setSummary(summaryData); + } catch (error) { + console.error("Failed to update request status:", error); + } + }; + if (!user) return null; - - const pendingRequests = collaborationRequests.filter(req => req.status === 'pending'); - + + const pendingRequests = collaborationRequests.filter( + (req) => req.status === "pending", + ); + + if (isLoading) { + return ( +
+
+
+ ); + } + return (
-

Welcome, {user.name}

-

Here's what's happening with your startup today

+

+ Welcome, {user.name} +

+

+ Here's what's happening with your startup today +

- + - +
- + {/* Summary cards */} -
+
@@ -62,13 +212,17 @@ export const EntrepreneurDashboard: React.FC = () => {
-

Pending Requests

-

{pendingRequests.length}

+

+ Pending Requests +

+

+ {summary?.pendingRequests || 0} +

- +
@@ -76,15 +230,17 @@ export const EntrepreneurDashboard: React.FC = () => {
-

Total Connections

+

+ Total Connections +

- {collaborationRequests.filter(req => req.status === 'accepted').length} + {summary?.totalConnections || 0}

- +
@@ -92,41 +248,75 @@ export const EntrepreneurDashboard: React.FC = () => {
-

Upcoming Meetings

-

2

+

+ Upcoming Meetings +

+

+ {summary?.upcomingMeetings || 0} +

- +
- +
-

Profile Views

-

24

+

+ New Messages +

+

+ {summary?.unreadMessages || 0} +

- + +
+
+ setIsDepositOpen(true)} + onWithdraw={() => setIsWithdrawOpen(true)} + onTransfer={() => setIsTransferOpen(true)} + /> +
+
+
+

+ Recent Transactions +

+ + View All + +
+ +
+
+
- {/* Collaboration requests */}
-

Collaboration Requests

+

+ Collaboration Requests +

{pendingRequests.length} pending
- + {collaborationRequests.length > 0 ? (
- {collaborationRequests.map(request => ( + {collaborationRequests.map((request) => ( {

No collaboration requests yet

-

When investors are interested in your startup, their requests will appear here

+

+ When investors are interested in your startup, their + requests will appear here +

)} + + {/* Upcoming Meetings List */} + {summary?.meetings && summary.meetings.length > 0 && ( + + +

+ Upcoming Meetings +

+
+ +
+ {summary.meetings.map((meeting: Meeting) => ( +
+
+
+ + {format(parseISO(meeting.startTime), "MMM")} + + + {format(parseISO(meeting.startTime), "d")} + +
+
+

+ {meeting.title} +

+
+ + + {format(parseISO(meeting.startTime), "h:mm a")} + + + + {meeting.location} + +
+
+
+ + + +
+ ))} +
+
+
+ )}
- - {/* Recommended investors */} + + {/* Sidebar - right side */}
+ {/* Recent Documents */} + + +

+ Recent Documents +

+ + View all + +
+ +
+ {recentDocuments.length > 0 ? ( + recentDocuments.map((doc) => ( +
+
+ +
+
+

+ {doc.name} +

+
+ + {doc.type} + + {doc.shared && ( + + Public + + )} +
+
+
+ )) + ) : ( +
+

+ No documents uploaded yet +

+ + + +
+ )} +
+
+
+ -

Recommended Investors

- +

+ Recommended Investors +

+ View all
- + - {recommendedInvestors.map(investor => ( + {recommendedInvestors.map((investor) => ( {
+ + setIsDepositOpen(false)} + onConfirm={handleDeposit} + isLoading={isPaymentLoading} + /> + setIsWithdrawOpen(false)} + onConfirm={handleWithdraw} + balance={balance} + isLoading={isPaymentLoading} + /> + setIsTransferOpen(false)} + onConfirm={handleTransfer} + balance={balance} + isLoading={isPaymentLoading} + />
); -}; \ No newline at end of file +}; diff --git a/src/pages/dashboard/InvestorDashboard.tsx b/src/pages/dashboard/InvestorDashboard.tsx index b72480abc..64b3355a0 100644 --- a/src/pages/dashboard/InvestorDashboard.tsx +++ b/src/pages/dashboard/InvestorDashboard.tsx @@ -1,107 +1,194 @@ -import React, { useState } from 'react'; -import { Link } from 'react-router-dom'; -import { Users, PieChart, Filter, Search, PlusCircle } from 'lucide-react'; -import { Button } from '../../components/ui/Button'; -import { Card, CardBody, CardHeader } from '../../components/ui/Card'; -import { Input } from '../../components/ui/Input'; -import { Badge } from '../../components/ui/Badge'; -import { EntrepreneurCard } from '../../components/entrepreneur/EntrepreneurCard'; -import { useAuth } from '../../context/AuthContext'; -import { Entrepreneur } from '../../types'; -import { entrepreneurs } from '../../data/users'; -import { getRequestsFromInvestor } from '../../data/collaborationRequests'; +import React, { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; +import { + Users, + MessageCircle, + Filter, + Search, + PlusCircle, + Calendar, + Clock, +} from "lucide-react"; +import { Button } from "../../components/ui/Button"; +import { Card, CardBody, CardHeader } from "../../components/ui/Card"; +import { Input } from "../../components/ui/Input"; +import { Badge } from "../../components/ui/Badge"; +import { EntrepreneurCard } from "../../components/entrepreneur/EntrepreneurCard"; +import { WalletCard } from "../../components/dashboard/WalletCard"; +import { TransactionHistory } from "../../components/dashboard/TransactionHistory"; +import { + DepositModal, + WithdrawModal, + TransferModal, +} from "../../components/dashboard/PaymentModals"; +import { useAuth } from "../../context/AuthContext"; +import { Entrepreneur, Meeting, Transaction } from "../../types"; +import api from "../../services/api"; +import dashboardService, { + DashboardSummary, +} from "../../services/dashboardService"; +import paymentService from "../../services/paymentService"; +import { format, parseISO } from "date-fns"; +import toast from "react-hot-toast"; export const InvestorDashboard: React.FC = () => { const { user } = useAuth(); - const [searchQuery, setSearchQuery] = useState(''); + const [summary, setSummary] = useState(null); + const [entrepreneurs, setEntrepreneurs] = useState([]); + const [transactions, setTransactions] = useState([]); + const [balance, setBalance] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); const [selectedIndustries, setSelectedIndustries] = useState([]); - + const [isLoading, setIsLoading] = useState(true); + const [isPaymentLoading, setIsPaymentLoading] = useState(false); + + // Modal states + const [isDepositOpen, setIsDepositOpen] = useState(false); + const [isWithdrawOpen, setIsWithdrawOpen] = useState(false); + const [isTransferOpen, setIsTransferOpen] = useState(false); + + useEffect(() => { + const fetchData = async () => { + if (user) { + setIsLoading(true); + try { + // Fetch summary stats + const summaryData = await dashboardService.getSummary(); + setSummary(summaryData); + + // Fetch entrepreneurs for directory from API + const entrepreneursRes = await api.get("/directory/entrepreneurs"); + setEntrepreneurs(entrepreneursRes.data); + + // Fetch wallet data + const [balanceData, transactionsData] = await Promise.all([ + paymentService.getBalance(), + paymentService.getTransactions(), + ]); + setBalance(balanceData); + setTransactions(transactionsData); + } catch (error) { + console.error("Failed to fetch investor dashboard data:", error); + } finally { + setIsLoading(false); + } + } + }; + fetchData(); + window.addEventListener("payment-updated", fetchData); + return () => window.removeEventListener("payment-updated", fetchData); + }, [user]); + + const handleDeposit = async (amount: number, method?: string) => { + setIsPaymentLoading(true); + try { + const res = await paymentService.deposit(amount, method); + setBalance(res.balance); + setTransactions([res.transaction, ...transactions]); + toast.success(res.message); + setIsDepositOpen(false); + } catch { + toast.error("Deposit failed"); + } finally { + setIsPaymentLoading(false); + } + }; + + const handleWithdraw = async (amount: number, method?: string) => { + setIsPaymentLoading(true); + try { + const res = await paymentService.withdraw(amount, method); + setBalance(res.balance); + setTransactions([res.transaction, ...transactions]); + toast.success(res.message); + setIsWithdrawOpen(false); + } catch { + toast.error("Withdrawal failed"); + } finally { + setIsPaymentLoading(false); + } + }; + + const handleTransfer = async (amount: number, data?: string) => { + try { + if (!data) return; + setIsPaymentLoading(true); + const { recipientId, description } = JSON.parse(data); + const res = await paymentService.transfer( + recipientId, + amount, + description, + ); + setBalance(res.balance); + setTransactions([res.transaction, ...transactions]); + toast.success(res.message); + setIsTransferOpen(false); + } catch { + toast.error("Transfer failed"); + } finally { + setIsPaymentLoading(false); + } + }; + if (!user) return null; - - // Get collaboration requests sent by this investor - const sentRequests = getRequestsFromInvestor(user.id); - const requestedEntrepreneurIds = sentRequests.map(req => req.entrepreneurId); - + // Filter entrepreneurs based on search and industry filters - const filteredEntrepreneurs = entrepreneurs.filter(entrepreneur => { - // Search filter - const matchesSearch = searchQuery === '' || + const filteredEntrepreneurs = entrepreneurs.filter((entrepreneur) => { + const matchesSearch = + searchQuery === "" || entrepreneur.name.toLowerCase().includes(searchQuery.toLowerCase()) || - entrepreneur.startupName.toLowerCase().includes(searchQuery.toLowerCase()) || + entrepreneur.startupName + .toLowerCase() + .includes(searchQuery.toLowerCase()) || entrepreneur.industry.toLowerCase().includes(searchQuery.toLowerCase()) || - entrepreneur.pitchSummary.toLowerCase().includes(searchQuery.toLowerCase()); - - // Industry filter - const matchesIndustry = selectedIndustries.length === 0 || + entrepreneur.pitchSummary + .toLowerCase() + .includes(searchQuery.toLowerCase()); + + const matchesIndustry = + selectedIndustries.length === 0 || selectedIndustries.includes(entrepreneur.industry); - + return matchesSearch && matchesIndustry; }); - - // Get unique industries for filter - const industries = Array.from(new Set(entrepreneurs.map(e => e.industry))); - - // Toggle industry selection + + const industries = Array.from(new Set(entrepreneurs.map((e) => e.industry))); + const toggleIndustry = (industry: string) => { - setSelectedIndustries(prevSelected => + setSelectedIndustries((prevSelected) => prevSelected.includes(industry) - ? prevSelected.filter(i => i !== industry) - : [...prevSelected, industry] + ? prevSelected.filter((i) => i !== industry) + : [...prevSelected, industry], ); }; - + + if (isLoading) { + return ( +
+
+
+ ); + } + return (
-

Discover Startups

-

Find and connect with promising entrepreneurs

+

+ Welcome back, {user.name} +

+

+ Discover and connect with innovative startups +

- + - +
- - {/* Filters and search */} -
-
- setSearchQuery(e.target.value)} - fullWidth - startAdornment={} - /> -
- -
-
- - Filter by: - -
- {industries.map(industry => ( - toggleIndustry(industry)} - > - {industry} - - ))} -
-
-
-
- + {/* Stats summary */} -
+
@@ -109,27 +196,35 @@ export const InvestorDashboard: React.FC = () => {
-

Total Startups

-

{entrepreneurs.length}

+

+ Total Startups +

+

+ {summary?.totalStartups || 0} +

- +
- +
-

Industries

-

{industries.length}

+

+ New Messages +

+

+ {summary?.unreadMessages || 0} +

- +
@@ -137,51 +232,231 @@ export const InvestorDashboard: React.FC = () => {
-

Your Connections

+

+ Connections +

- {sentRequests.filter(req => req.status === 'accepted').length} + {summary?.totalConnections || 0}

-
- - {/* Entrepreneurs grid */} -
- - -

Featured Startups

-
- + + - {filteredEntrepreneurs.length > 0 ? ( -
- {filteredEntrepreneurs.map(entrepreneur => ( - - ))} +
+
+
- ) : ( -
-

No startups match your filters

- +
+

Upcoming

+

+ {summary?.upcomingMeetings || 0} +

- )} +
+ +
+
+ setIsDepositOpen(true)} + onWithdraw={() => setIsWithdrawOpen(true)} + onTransfer={() => setIsTransferOpen(true)} + /> +
+
+
+

+ Recent Transactions +

+ + View All + +
+ +
+
+ +
+
+ {/* Filters and search */} + + +
+
+ setSearchQuery(e.target.value)} + fullWidth + startAdornment={} + /> +
+ +
+ + + Filter by: + + +
+ {industries.slice(0, 3).map((industry) => ( + toggleIndustry(industry)} + > + {industry} + + ))} + {industries.length > 3 && ( + + +{industries.length - 3} more + + )} +
+
+
+
+
+ + {/* Entrepreneurs grid */} +
+ + +

+ Recommended Startups +

+
+ + + {filteredEntrepreneurs.length > 0 ? ( +
+ {filteredEntrepreneurs.slice(0, 4).map((entrepreneur) => ( + + ))} +
+ ) : ( +
+

+ No startups match your filters +

+
+ )} +
+
+
+
+ +
+ {/* Upcoming Meetings List */} + {summary?.meetings && summary.meetings.length > 0 && ( + + +

Schedule

+
+ +
+ {summary.meetings.map((meeting: Meeting) => ( +
+
+
+ + {format(parseISO(meeting.startTime), "MMM")} + + + {format(parseISO(meeting.startTime), "d")} + +
+
+

+ {meeting.title} +

+

+ {format(parseISO(meeting.startTime), "h:mm a")} +

+
+
+ + + +
+ ))} +
+
+
+ )} + + {/* Quick Actions */} + + +

+ Quick Actions +

+
+ + + + + + + + +
+
+
+ + setIsDepositOpen(false)} + onConfirm={handleDeposit} + isLoading={isPaymentLoading} + /> + setIsWithdrawOpen(false)} + onConfirm={handleWithdraw} + balance={balance} + isLoading={isPaymentLoading} + /> + setIsTransferOpen(false)} + onConfirm={handleTransfer} + balance={balance} + isLoading={isPaymentLoading} + />
); -}; \ No newline at end of file +}; diff --git a/src/pages/dashboard/TransactionsPage.tsx b/src/pages/dashboard/TransactionsPage.tsx new file mode 100644 index 000000000..7ca61f99f --- /dev/null +++ b/src/pages/dashboard/TransactionsPage.tsx @@ -0,0 +1,212 @@ +import React, { useState, useEffect } from "react"; +import { Download, Filter, Search, RefreshCcw } from "lucide-react"; +import { Card, CardBody } from "../../components/ui/Card"; +import { Button } from "../../components/ui/Button"; +import { Input } from "../../components/ui/Input"; +import { Badge } from "../../components/ui/Badge"; +import paymentService from "../../services/paymentService"; +import { Transaction } from "../../types"; +import { format, parseISO } from "date-fns"; + +export const TransactionsPage: React.FC = () => { + const [transactions, setTransactions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [filterType, setFilterType] = useState< + "all" | "deposit" | "withdraw" | "transfer" + >("all"); + + useEffect(() => { + fetchTransactions(); + }, []); + + const fetchTransactions = async () => { + setIsLoading(true); + try { + const data = await paymentService.getTransactions(); + setTransactions(data); + } catch (error) { + console.error("Failed to fetch transactions:", error); + } finally { + setIsLoading(false); + } + }; + + const filteredTransactions = transactions.filter((t) => { + const transactionId = t.id || ""; + const matchesSearch = + t.description?.toLowerCase().includes(searchQuery.toLowerCase()) || + transactionId.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesType = filterType === "all" || t.type === filterType; + return matchesSearch && matchesType; + }); + + return ( +
+
+
+

+ Transaction History +

+

+ View and manage all your financial activities +

+
+
+ + +
+
+ + + +
+
+ setSearchQuery(e.target.value)} + startAdornment={} + fullWidth + /> +
+
+ + +
+
+ +
+ + + + + + + + + + + + {isLoading ? ( + + + + ) : filteredTransactions.length === 0 ? ( + + + + ) : ( + filteredTransactions.map((t) => ( + + + + + + + + )) + )} + +
+ Date + + Description + + Type + + Status + + Amount +
+
+
+
+
+ No transactions found matching your filters. +
+ {format(parseISO(t.createdAt), "MMM d, yyyy HH:mm")} + +
+ {t.description} +
+
+ {t.id} +
+
+ + {t.type.charAt(0).toUpperCase() + t.type.slice(1)} + + + + {t.status} + + 0 ? "text-green-600" : "text-red-600" + }`} + > + {t.amount > 0 ? "+" : "-"}$ + {Math.abs(t.amount).toLocaleString()} +
+
+
+
+
+ ); +}; diff --git a/src/pages/deals/DealsPage.tsx b/src/pages/deals/DealsPage.tsx index ea26870c5..b872a15cc 100644 --- a/src/pages/deals/DealsPage.tsx +++ b/src/pages/deals/DealsPage.tsx @@ -1,97 +1,344 @@ -import React, { useState } from 'react'; -import { Search, Filter, DollarSign, TrendingUp, Users, Calendar } from 'lucide-react'; -import { Card, CardHeader, CardBody } from '../../components/ui/Card'; -import { Input } from '../../components/ui/Input'; -import { Button } from '../../components/ui/Button'; -import { Badge } from '../../components/ui/Badge'; -import { Avatar } from '../../components/ui/Avatar'; - -const deals = [ - { - id: 1, - startup: { - name: 'TechWave AI', - logo: 'https://images.pexels.com/photos/774909/pexels-photo-774909.jpeg', - industry: 'FinTech' - }, - amount: '$1.5M', - equity: '15%', - status: 'Due Diligence', - stage: 'Series A', - lastActivity: '2024-02-15' - }, - { - id: 2, - startup: { - name: 'GreenLife Solutions', - logo: 'https://images.pexels.com/photos/614810/pexels-photo-614810.jpeg', - industry: 'CleanTech' - }, - amount: '$2M', - equity: '20%', - status: 'Term Sheet', - stage: 'Seed', - lastActivity: '2024-02-10' - }, - { - id: 3, - startup: { - name: 'HealthPulse', - logo: 'https://images.pexels.com/photos/415829/pexels-photo-415829.jpeg', - industry: 'HealthTech' - }, - amount: '$800K', - equity: '12%', - status: 'Negotiation', - stage: 'Pre-seed', - lastActivity: '2024-02-05' - } -]; +import React, { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; +import { + Search, + Filter, + DollarSign, + TrendingUp, + Users, + Calendar, + X, +} from "lucide-react"; +import { Card, CardHeader, CardBody } from "../../components/ui/Card"; +import { Input } from "../../components/ui/Input"; +import { Button } from "../../components/ui/Button"; +import { Badge } from "../../components/ui/Badge"; +import { Avatar } from "../../components/ui/Avatar"; +import { useAuth } from "../../context/AuthContext"; +import { Deal, User as UserType } from "../../types"; +import dealService from "../../services/dealService"; +import api from "../../services/api"; +import toast from "react-hot-toast"; export const DealsPage: React.FC = () => { - const [searchQuery, setSearchQuery] = useState(''); + const { user } = useAuth(); + const [deals, setDeals] = useState([]); + const [entrepreneurs, setEntrepreneurs] = useState>( + {}, + ); + const [allEntrepreneurs, setAllEntrepreneurs] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); const [selectedStatus, setSelectedStatus] = useState([]); - - const statuses = ['Due Diligence', 'Term Sheet', 'Negotiation', 'Closed', 'Passed']; - + const [isLoading, setIsLoading] = useState(true); + const [showAddModal, setShowAddModal] = useState(false); + + // New deal form state + const [newDeal, setNewDeal] = useState({ + entrepreneurId: "", + amount: "", + equity: "", + stage: "Seed", + status: "Due Diligence", + notes: "", + }); + + const statuses = [ + "Due Diligence", + "Term Sheet", + "Negotiation", + "Closed", + "Passed", + ]; + + useEffect(() => { + const fetchDeals = async () => { + if (user) { + try { + const dealsData = await dealService.getDeals(); + setDeals(dealsData); + + // Fetch entrepreneur details + const entrepreneurIds = new Set( + dealsData.map((d) => d.entrepreneurId), + ); + const entrepreneurPromises = Array.from(entrepreneurIds).map((id) => + api.get(`/users/${id}`), + ); + const entrepreneurResponses = await Promise.all(entrepreneurPromises); + + const entrepreneursMap: Record = {}; + entrepreneurResponses.forEach((response) => { + entrepreneursMap[response.data.id] = response.data; + }); + setEntrepreneurs(entrepreneursMap); + + // Fetch all entrepreneurs for dropdown + const allEntrepreneursRes = await api.get("/directory/entrepreneurs"); + setAllEntrepreneurs(allEntrepreneursRes.data); + } catch (error) { + console.error("Failed to fetch deals:", error); + } finally { + setIsLoading(false); + } + } + }; + fetchDeals(); + }, [user]); + + const handleCreateDeal = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const createdDeal = await dealService.createDeal(newDeal); + setDeals((prev) => [createdDeal, ...prev]); + + // Fetch entrepreneur details + const entrepreneurRes = await api.get(`/users/${newDeal.entrepreneurId}`); + setEntrepreneurs((prev) => ({ + ...prev, + [newDeal.entrepreneurId]: entrepreneurRes.data, + })); + + toast.success("Deal created successfully"); + setShowAddModal(false); + setNewDeal({ + entrepreneurId: "", + amount: "", + equity: "", + stage: "Seed", + status: "Due Diligence", + notes: "", + }); + } catch (error) { + console.error("Failed to create deal:", error); + toast.error("Failed to create deal"); + } + }; + const toggleStatus = (status: string) => { - setSelectedStatus(prev => + setSelectedStatus((prev) => prev.includes(status) - ? prev.filter(s => s !== status) - : [...prev, status] + ? prev.filter((s) => s !== status) + : [...prev, status], ); }; - + const getStatusColor = (status: string) => { switch (status) { - case 'Due Diligence': - return 'primary'; - case 'Term Sheet': - return 'secondary'; - case 'Negotiation': - return 'accent'; - case 'Closed': - return 'success'; - case 'Passed': - return 'error'; + case "Due Diligence": + return "primary"; + case "Term Sheet": + return "secondary"; + case "Negotiation": + return "accent"; + case "Closed": + return "success"; + case "Passed": + return "error"; default: - return 'gray'; + return "gray"; } }; - + + const filteredDeals = deals.filter((deal) => { + const entrepreneur = entrepreneurs[deal.entrepreneurId]; + const matchesSearch = + searchQuery === "" || + entrepreneur?.name.toLowerCase().includes(searchQuery.toLowerCase()) || + entrepreneur?.startupName + ?.toLowerCase() + .includes(searchQuery.toLowerCase()) || + entrepreneur?.industry?.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesStatus = + selectedStatus.length === 0 || selectedStatus.includes(deal.status); + + return matchesSearch && matchesStatus; + }); + + // Calculate stats + const totalInvestment = deals + .filter((d) => d.status === "Closed") + .reduce((sum, d) => { + const amount = parseFloat(d.amount.replace(/[$,M]/g, "")); + return sum + (isNaN(amount) ? 0 : amount); + }, 0); + + const activeDeals = deals.filter( + (d) => d.status !== "Closed" && d.status !== "Passed", + ).length; + + const portfolioCompanies = deals.filter((d) => d.status === "Closed").length; + + const closedThisMonth = deals.filter((d) => { + if (d.status !== "Closed") return false; + const dealDate = new Date(d.lastActivity); + const now = new Date(); + return ( + dealDate.getMonth() === now.getMonth() && + dealDate.getFullYear() === now.getFullYear() + ); + }).length; + + if (isLoading) { + return ( +
+
+
+ ); + } + return (

Investment Deals

-

Track and manage your investment pipeline

+

+ Track and manage your investment pipeline +

- - + +
- + + {/* Add Deal Modal */} + {showAddModal && ( +
+
+
+

+ Add New Deal +

+ +
+ +
+
+ + +
+ +
+
+ + + setNewDeal({ ...newDeal, amount: e.target.value }) + } + /> +
+ +
+ + + setNewDeal({ ...newDeal, equity: e.target.value }) + } + /> +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
- +
- +
); -}; \ No newline at end of file +}; diff --git a/src/pages/investors/InvestorsPage.tsx b/src/pages/investors/InvestorsPage.tsx index 0f234cf4d..155a54f15 100644 --- a/src/pages/investors/InvestorsPage.tsx +++ b/src/pages/investors/InvestorsPage.tsx @@ -1,61 +1,102 @@ -import React, { useState } from 'react'; -import { Search, Filter, MapPin } from 'lucide-react'; -import { Input } from '../../components/ui/Input'; -import { Card, CardHeader, CardBody } from '../../components/ui/Card'; -import { Badge } from '../../components/ui/Badge'; -import { InvestorCard } from '../../components/investor/InvestorCard'; -import { investors } from '../../data/users'; +import React, { useState, useEffect } from "react"; +import { Search, Filter } from "lucide-react"; +import { Input } from "../../components/ui/Input"; +import { Card, CardHeader, CardBody } from "../../components/ui/Card"; +import { Badge } from "../../components/ui/Badge"; +import { InvestorCard } from "../../components/investor/InvestorCard"; +import { Investor } from "../../types"; +import api from "../../services/api"; export const InvestorsPage: React.FC = () => { - const [searchQuery, setSearchQuery] = useState(''); + const [investors, setInvestors] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); const [selectedStages, setSelectedStages] = useState([]); const [selectedInterests, setSelectedInterests] = useState([]); - + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchInvestors = async () => { + setIsLoading(true); + try { + const res = await api.get("/directory/investors"); + setInvestors(res.data); + } catch (error) { + console.error("Failed to fetch investors:", error); + } finally { + setIsLoading(false); + } + }; + fetchInvestors(); + }, []); + // Get unique investment stages and interests - const allStages = Array.from(new Set(investors.flatMap(i => i.investmentStage))); - const allInterests = Array.from(new Set(investors.flatMap(i => i.investmentInterests))); - + const allStages = Array.from( + new Set(investors.flatMap((i) => i.investmentStage || [])), + ); + const allInterests = Array.from( + new Set(investors.flatMap((i) => i.investmentInterests || [])), + ); + // Filter investors based on search and filters - const filteredInvestors = investors.filter(investor => { - const matchesSearch = searchQuery === '' || + const filteredInvestors = investors.filter((investor) => { + const matchesSearch = + searchQuery === "" || investor.name.toLowerCase().includes(searchQuery.toLowerCase()) || - investor.bio.toLowerCase().includes(searchQuery.toLowerCase()) || - investor.investmentInterests.some(interest => - interest.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const matchesStages = selectedStages.length === 0 || - investor.investmentStage.some(stage => selectedStages.includes(stage)); - - const matchesInterests = selectedInterests.length === 0 || - investor.investmentInterests.some(interest => selectedInterests.includes(interest)); - + (investor.bio && + investor.bio.toLowerCase().includes(searchQuery.toLowerCase())) || + (investor.investmentInterests && + investor.investmentInterests.some((interest) => + interest.toLowerCase().includes(searchQuery.toLowerCase()), + )); + + const matchesStages = + selectedStages.length === 0 || + (investor.investmentStage && + investor.investmentStage.some((stage) => + selectedStages.includes(stage), + )); + + const matchesInterests = + selectedInterests.length === 0 || + (investor.investmentInterests && + investor.investmentInterests.some((interest) => + selectedInterests.includes(interest), + )); + return matchesSearch && matchesStages && matchesInterests; }); - + const toggleStage = (stage: string) => { - setSelectedStages(prev => - prev.includes(stage) - ? prev.filter(s => s !== stage) - : [...prev, stage] + setSelectedStages((prev) => + prev.includes(stage) ? prev.filter((s) => s !== stage) : [...prev, stage], ); }; - + const toggleInterest = (interest: string) => { - setSelectedInterests(prev => + setSelectedInterests((prev) => prev.includes(interest) - ? prev.filter(i => i !== interest) - : [...prev, interest] + ? prev.filter((i) => i !== interest) + : [...prev, interest], ); }; - + + if (isLoading) { + return ( +
+
+
+ ); + } + return (

Find Investors

-

Connect with investors who match your startup's needs

+

+ Connect with investors who match your startup's needs +

- +
{/* Filters sidebar */}
@@ -65,16 +106,18 @@ export const InvestorsPage: React.FC = () => {
-

Investment Stage

+

+ Investment Stage +

- {allStages.map(stage => ( + {allStages.map((stage) => (
- +
-

Investment Interests

+

+ Investment Interests +

- {allInterests.map(interest => ( + {allInterests.map((interest) => ( toggleInterest(interest)} > @@ -98,28 +147,10 @@ export const InvestorsPage: React.FC = () => { ))}
- -
-

Location

-
- - - -
-
- + {/* Main content */}
@@ -130,7 +161,7 @@ export const InvestorsPage: React.FC = () => { startAdornment={} fullWidth /> - +
@@ -138,17 +169,14 @@ export const InvestorsPage: React.FC = () => {
- +
- {filteredInvestors.map(investor => ( - + {filteredInvestors.map((investor) => ( + ))}
); -}; \ No newline at end of file +}; diff --git a/src/pages/meetings/MeetingsPage.tsx b/src/pages/meetings/MeetingsPage.tsx new file mode 100644 index 000000000..069e5b11c --- /dev/null +++ b/src/pages/meetings/MeetingsPage.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { MeetingCalendar } from "../../components/meetings/MeetingCalendar"; + +export const MeetingsPage: React.FC = () => { + return ( +
+
+

Your Schedule

+

Manage your meetings and appointments

+
+ + +
+ ); +}; + +export default MeetingsPage; diff --git a/src/pages/messages/MessagesPage.tsx b/src/pages/messages/MessagesPage.tsx index c7589fb80..103d5369b 100644 --- a/src/pages/messages/MessagesPage.tsx +++ b/src/pages/messages/MessagesPage.tsx @@ -1,18 +1,43 @@ -import React from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../context/AuthContext'; -import { getConversationsForUser } from '../../data/messages'; -import { ChatUserList } from '../../components/chat/ChatUserList'; -// import { MessageCircle } from 'lucide-react'; +import React, { useState, useEffect } from "react"; +import { useAuth } from "../../context/AuthContext"; +import { messageService } from "../../services/messageService"; +import { ChatUserList } from "../../components/chat/ChatUserList"; +import { ChatConversation } from "../../types"; +import { MessageCircle } from "lucide-react"; export const MessagesPage: React.FC = () => { const { user } = useAuth(); - const navigate = useNavigate(); - + const [conversations, setConversations] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchConversations = async () => { + if (user) { + try { + const data = await messageService.getConversations(); + setConversations(data); + } catch (error) { + console.error("Failed to fetch conversations:", error); + } finally { + setIsLoading(false); + } + } + }; + fetchConversations(); + const interval = setInterval(fetchConversations, 5000); // Poll for new messages/conversations + return () => clearInterval(interval); + }, [user]); + if (!user) return null; - - const conversations = getConversationsForUser(user.id); - + + if (isLoading) { + return ( +
+
+
+ ); + } + return (
{conversations.length > 0 ? ( @@ -20,14 +45,15 @@ export const MessagesPage: React.FC = () => { ) : (
- {/* */} +

No messages yet

- Start connecting with entrepreneurs and investors to begin conversations + Start connecting with entrepreneurs and investors to begin + conversations

)}
); -}; \ No newline at end of file +}; diff --git a/src/pages/notifications/NotificationsPage.tsx b/src/pages/notifications/NotificationsPage.tsx index 3bb806c25..a70a37f11 100644 --- a/src/pages/notifications/NotificationsPage.tsx +++ b/src/pages/notifications/NotificationsPage.tsx @@ -1,112 +1,208 @@ -import React from 'react'; -import { Bell, MessageCircle, UserPlus, DollarSign } from 'lucide-react'; -import { Card, CardBody } from '../../components/ui/Card'; -import { Avatar } from '../../components/ui/Avatar'; -import { Badge } from '../../components/ui/Badge'; -import { Button } from '../../components/ui/Button'; - -const notifications = [ - { - id: 1, - type: 'message', - user: { - name: 'Sarah Johnson', - avatar: 'https://images.pexels.com/photos/774909/pexels-photo-774909.jpeg' - }, - content: 'sent you a message about your startup', - time: '5 minutes ago', - unread: true - }, - { - id: 2, - type: 'connection', - user: { - name: 'Michael Rodriguez', - avatar: 'https://images.pexels.com/photos/2379004/pexels-photo-2379004.jpeg' - }, - content: 'accepted your connection request', - time: '2 hours ago', - unread: true - }, - { - id: 3, - type: 'investment', - user: { - name: 'Jennifer Lee', - avatar: 'https://images.pexels.com/photos/1181686/pexels-photo-1181686.jpeg' - }, - content: 'showed interest in investing in your startup', - time: '1 day ago', - unread: false - } -]; +import React, { useState, useEffect, useCallback } from "react"; +import { Link } from "react-router-dom"; +import { + Bell, + MessageCircle, + UserPlus, + DollarSign, + Calendar, + Trash2, +} from "lucide-react"; +import { Card, CardBody } from "../../components/ui/Card"; +import { Badge } from "../../components/ui/Badge"; +import { Button } from "../../components/ui/Button"; +import { useAuth } from "../../context/AuthContext"; +import { notificationService } from "../../services/notificationService"; +import { Notification } from "../../types"; +import { formatDistanceToNow, parseISO } from "date-fns"; +import toast from "react-hot-toast"; export const NotificationsPage: React.FC = () => { + const { user } = useAuth(); + const [notifications, setNotifications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const fetchNotifications = useCallback(async () => { + if (user) { + try { + const data = await notificationService.getNotifications(); + setNotifications(data); + } catch (error) { + console.error("Failed to fetch notifications:", error); + } finally { + setIsLoading(false); + } + } + }, [user]); + + useEffect(() => { + fetchNotifications(); + const interval = setInterval(fetchNotifications, 10000); + return () => clearInterval(interval); + }, [fetchNotifications]); + + const handleMarkAsRead = async (id: string) => { + try { + await notificationService.markAsRead(id); + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)), + ); + } catch (error) { + console.error("Failed to mark as read:", error); + } + }; + + const handleMarkAllAsRead = async () => { + try { + await notificationService.markAllAsRead(); + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); + toast.success("All notifications marked as read"); + } catch (error) { + console.error("Failed to mark all as read:", error); + } + }; + + const handleDeleteNotification = async (id: string, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + try { + await notificationService.deleteNotification(id); + setNotifications((prev) => prev.filter((n) => n.id !== id)); + toast.success("Notification deleted"); + } catch (error) { + console.error("Failed to delete notification:", error); + } + }; + const getNotificationIcon = (type: string) => { switch (type) { - case 'message': - return ; - case 'connection': - return ; - case 'investment': - return ; + case "message": + return ; + case "collaboration_request": + return ; + case "collaboration_accepted": + return ; + case "meeting_scheduled": + case "meeting_status": + return ; default: - return ; + return ; } }; - + + if (isLoading) { + return ( +
+
+
+ ); + } + return ( -
+

Notifications

-

Stay updated with your network activity

+

+ Stay updated with your latest network activity +

- - + + {notifications.length > 0 && ( + + )}
- -
- {notifications.map(notification => ( - - - - -
-
- - {notification.user.name} - - {notification.unread && ( - New - )} -
- -

- {notification.content} -

- -
- {getNotificationIcon(notification.type)} - {notification.time} -
-
-
-
- ))} + +
+ {notifications.length > 0 ? ( + notifications.map((notification) => ( + + !notification.isRead && handleMarkAsRead(notification.id) + } + className="block" + > + + +
+ {getNotificationIcon(notification.type)} +
+ +
+
+
+

+ {notification.title} +

+ {!notification.isRead && ( + + New + + )} +
+ + {formatDistanceToNow(parseISO(notification.createdAt), { + addSuffix: true, + })} + +
+ +

+ {notification.message} +

+
+ + +
+
+ + )) + ) : ( +
+
+ +
+

+ No notifications +

+

+ We'll let you know when something important happens. +

+
+ )}
); -}; \ No newline at end of file +}; diff --git a/src/pages/profile/EntrepreneurProfile.tsx b/src/pages/profile/EntrepreneurProfile.tsx index 3c05a18e6..e76211a1f 100644 --- a/src/pages/profile/EntrepreneurProfile.tsx +++ b/src/pages/profile/EntrepreneurProfile.tsx @@ -1,56 +1,171 @@ -import React from 'react'; -import { useParams, Link } from 'react-router-dom'; -import { MessageCircle, Users, Calendar, Building2, MapPin, UserCircle, FileText, DollarSign, Send } from 'lucide-react'; -import { Avatar } from '../../components/ui/Avatar'; -import { Button } from '../../components/ui/Button'; -import { Card, CardBody, CardHeader } from '../../components/ui/Card'; -import { Badge } from '../../components/ui/Badge'; -import { useAuth } from '../../context/AuthContext'; -import { findUserById } from '../../data/users'; -import { createCollaborationRequest, getRequestsFromInvestor } from '../../data/collaborationRequests'; -import { Entrepreneur } from '../../types'; +import React, { useState, useEffect } from "react"; +import { useParams, Link } from "react-router-dom"; +import { + MessageCircle, + Users, + Calendar, + Building2, + MapPin, + UserCircle, + FileText, + DollarSign, + Send, + CalendarDays, +} from "lucide-react"; +import toast from "react-hot-toast"; +import { ScheduleMeetingModal } from "../../components/meetings/ScheduleMeetingModal"; +import { Avatar } from "../../components/ui/Avatar"; +import { Button } from "../../components/ui/Button"; +import { Card, CardBody, CardHeader } from "../../components/ui/Card"; +import { Badge } from "../../components/ui/Badge"; +import { useAuth } from "../../context/AuthContext"; +import { Entrepreneur, CollaborationRequest } from "../../types"; +import api from "../../services/api"; +import { useSocket } from "../../context/SocketContext"; export const EntrepreneurProfile: React.FC = () => { const { id } = useParams<{ id: string }>(); + const { userStatuses } = useSocket(); const { user: currentUser } = useAuth(); - - // Fetch entrepreneur data - const entrepreneur = findUserById(id || '') as Entrepreneur | null; - - if (!entrepreneur || entrepreneur.role !== 'entrepreneur') { + const [entrepreneur, setEntrepreneur] = useState(null); + const [collaborationRequests, setCollaborationRequests] = useState< + CollaborationRequest[] + >([]); + const [isMeetingModalOpen, setIsMeetingModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchProfileData = async () => { + if (!id) return; + + setIsLoading(true); + try { + const res = await api.get(`/users/${id}`); + setEntrepreneur(res.data); + + // If current user is an investor, check for collaboration requests + if (currentUser?.role === "investor") { + const requestsRes = await api.get("/collaboration"); + setCollaborationRequests(requestsRes.data); + } + } catch (error) { + console.error("Failed to fetch entrepreneur profile:", error); + toast.error("Failed to load profile data. Please try again."); + } finally { + setIsLoading(false); + } + }; + + fetchProfileData(); + }, [id, currentUser]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!entrepreneur || entrepreneur.role !== "entrepreneur") { return (
-

Entrepreneur not found

-

The entrepreneur profile you're looking for doesn't exist or has been removed.

+

+ Entrepreneur not found +

+

+ The entrepreneur profile you're looking for doesn't exist or has been + removed. +

- +
); } - + const isCurrentUser = currentUser?.id === entrepreneur.id; - const isInvestor = currentUser?.role === 'investor'; - - // Check if the current investor has already sent a request to this entrepreneur - const hasRequestedCollaboration = isInvestor && id - ? getRequestsFromInvestor(currentUser.id).some(req => req.entrepreneurId === id) - : false; - - const handleSendRequest = () => { - if (isInvestor && currentUser && id) { - createCollaborationRequest( - currentUser.id, - id, - `I'm interested in learning more about ${entrepreneur.startupName} and would like to explore potential investment opportunities.` + const isInvestor = currentUser?.role === "investor"; + + const collaborationRequest = + isInvestor && id + ? collaborationRequests.find( + (req) => + req.entrepreneurId === id && req.investorId === currentUser.id, + ) + : null; + + const hasRequestedCollaboration = !!collaborationRequest; + const isCollaborationAccepted = collaborationRequest?.status === "accepted"; + + const handleSendRequest = async () => { + console.log("[DEBUG] handleSendRequest called", { + isInvestor, + currentUserRole: currentUser?.role, + targetId: id, + }); + + if (!currentUser) { + toast.error("Please log in to request collaboration"); + return; + } + + if (!isInvestor) { + toast.error( + "Only investors can request collaboration with entrepreneurs", + ); + return; + } + + if (!id) { + toast.error("Invalid entrepreneur profile"); + return; + } + + try { + const loadingToast = toast.loading("Sending request..."); + console.log("[DEBUG] POST /collaboration payload:", { + entrepreneurId: id, + }); + + const res = await api.post("/collaboration", { + entrepreneurId: id, + message: `I'm interested in learning more about ${entrepreneur.startupName} and would like to explore potential investment opportunities.`, + }); + + console.log("[DEBUG] POST /collaboration response:", res.data); + toast.success("Collaboration request sent!", { id: loadingToast }); + + // Refresh collaboration requests + const requestsRes = await api.get("/collaboration"); + console.log( + "[DEBUG] GET /collaboration refreshed count:", + requestsRes.data.length, ); - - // In a real app, we would refresh the data or update state - // For this demo, we'll force a page reload - window.location.reload(); + setCollaborationRequests(requestsRes.data); + } catch (error: unknown) { + console.error("[DEBUG] Collaboration request failed:", error); + let message = "Failed to send request"; + if ( + error && + typeof error === "object" && + "response" in error && + error.response && + typeof error.response === "object" && + "data" in error.response && + error.response.data && + typeof error.response.data === "object" && + "message" in error.response.data && + typeof error.response.data.message === "string" + ) { + message = error.response.data.message; + } + toast.error(message); } }; - + return (
{/* Profile header */} @@ -61,35 +176,41 @@ export const EntrepreneurProfile: React.FC = () => { src={entrepreneur.avatarUrl} alt={entrepreneur.name} size="xl" - status={entrepreneur.isOnline ? 'online' : 'offline'} + status={userStatuses[entrepreneur.id]?.status || "offline"} className="mx-auto sm:mx-0" /> - +
-

{entrepreneur.name}

+

+ {entrepreneur.name} +

Founder at {entrepreneur.startupName}

- +
{entrepreneur.industry} {entrepreneur.location} - - - Founded {entrepreneur.foundedYear} - - - - {entrepreneur.teamSize} team members - + {entrepreneur.foundedYear && ( + + + Founded {entrepreneur.foundedYear} + + )} + {entrepreneur.teamSize && ( + + + {entrepreneur.teamSize} team members + + )}
- +
{!isCurrentUser && ( <> @@ -101,31 +222,47 @@ export const EntrepreneurProfile: React.FC = () => { Message - + {isInvestor && ( - + <> + + + )} )} - + {isCurrentUser && ( - + + + )}
- +
{/* Main content - left side */}
@@ -135,53 +272,51 @@ export const EntrepreneurProfile: React.FC = () => {

About

-

{entrepreneur.bio}

+

+ {entrepreneur.bio || "No bio provided."} +

- + {/* Startup Description */} -

Startup Overview

+

+ Startup Overview +

-

Problem Statement

-

- {entrepreneur?.pitchSummary?.split('.')[0]}. -

-
- -
-

Solution

-

- {entrepreneur.pitchSummary} -

-
- -
-

Market Opportunity

+

+ Pitch Summary +

- The {entrepreneur.industry} market is experiencing significant growth, with a projected CAGR of 14.5% through 2027. Our solution addresses key pain points in this expanding market. + {entrepreneur.pitchSummary || "No pitch summary provided."}

- +
-

Competitive Advantage

+

+ Market Opportunity +

- Unlike our competitors, we offer a unique approach that combines innovative technology with deep industry expertise, resulting in superior outcomes for our customers. + The {entrepreneur.industry} market is experiencing + significant growth. Our solution addresses key pain points + in this expanding market.

- + {/* Team */}

Team

- {entrepreneur.teamSize} members + + {entrepreneur.teamSize || 1} members +
@@ -193,47 +328,17 @@ export const EntrepreneurProfile: React.FC = () => { className="mr-3" />
-

{entrepreneur.name}

+

+ {entrepreneur.name} +

Founder & CEO

- -
- -
-

Alex Johnson

-

CTO

-
-
- -
- -
-

Jessica Chen

-

Head of Product

-
-
- - {entrepreneur.teamSize > 3 && ( -
-

+ {entrepreneur.teamSize - 3} more team members

-
- )}
- + {/* Sidebar - right side */}
{/* Funding Details */} @@ -247,41 +352,15 @@ export const EntrepreneurProfile: React.FC = () => { Current Round
-

{entrepreneur.fundingNeeded}

-
-
- -
- Valuation -

$8M - $12M

-
- -
- Previous Funding -

$750K Seed (2022)

-
- -
- Funding Timeline -
-
- Pre-seed - Completed -
-
- Seed - Completed -
-
- Series A - In Progress -
+

+ {entrepreneur.fundingNeeded || "Undisclosed"} +

- + {/* Documents */} @@ -294,62 +373,47 @@ export const EntrepreneurProfile: React.FC = () => {
-

Pitch Deck

-

Updated 2 months ago

+

+ Pitch Deck +

+

+ Request access to view +

- -
- -
-
- -
-
-

Business Plan

-

Updated 1 month ago

-
- -
- -
-
- -
-
-

Financial Projections

-

Updated 2 weeks ago

-
-
- + {!isCurrentUser && isInvestor && (

- Request access to detailed documents and financials by sending a collaboration request. + Request access to detailed documents and financials by + sending a collaboration request.

- - {!hasRequestedCollaboration ? ( - - ) : ( - - )} + +
)}
+ + {currentUser?.id && id && ( + setIsMeetingModalOpen(false)} + investorId={currentUser.id} + entrepreneurId={id} + /> + )}
); -}; \ No newline at end of file +}; diff --git a/src/pages/profile/InvestorProfile.tsx b/src/pages/profile/InvestorProfile.tsx index 22b722d62..cbe8e0d57 100644 --- a/src/pages/profile/InvestorProfile.tsx +++ b/src/pages/profile/InvestorProfile.tsx @@ -1,35 +1,73 @@ -import React from 'react'; -import { useParams, Link } from 'react-router-dom'; -import { MessageCircle, Building2, MapPin, UserCircle, BarChart3, Briefcase } from 'lucide-react'; -import { Avatar } from '../../components/ui/Avatar'; -import { Button } from '../../components/ui/Button'; -import { Card, CardBody, CardHeader } from '../../components/ui/Card'; -import { Badge } from '../../components/ui/Badge'; -import { useAuth } from '../../context/AuthContext'; -import { findUserById } from '../../data/users'; -import { Investor } from '../../types'; +import React, { useState, useEffect } from "react"; +import { useParams, Link } from "react-router-dom"; +import { + MessageCircle, + Building2, + MapPin, + UserCircle, + BarChart3, +} from "lucide-react"; +import { Avatar } from "../../components/ui/Avatar"; +import { Button } from "../../components/ui/Button"; +import { Card, CardBody, CardHeader } from "../../components/ui/Card"; +import { Badge } from "../../components/ui/Badge"; +import { useAuth } from "../../context/AuthContext"; +import { Investor } from "../../types"; +import api from "../../services/api"; +import { useSocket } from "../../context/SocketContext"; export const InvestorProfile: React.FC = () => { const { id } = useParams<{ id: string }>(); + const { userStatuses } = useSocket(); const { user: currentUser } = useAuth(); - - // Fetch investor data - const investor = findUserById(id || '') as Investor | null; - - if (!investor || investor.role !== 'investor') { + const [investor, setInvestor] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchProfileData = async () => { + if (!id) return; + + setIsLoading(true); + try { + const res = await api.get(`/users/${id}`); + setInvestor(res.data); + } catch (error) { + console.error("Failed to fetch investor profile:", error); + } finally { + setIsLoading(false); + } + }; + + fetchProfileData(); + }, [id]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!investor || investor.role !== "investor") { return (

Investor not found

-

The investor profile you're looking for doesn't exist or has been removed.

+

+ The investor profile you're looking for doesn't exist or has been + removed. +

- +
); } - + const isCurrentUser = currentUser?.id === investor.id; - + return (
{/* Profile header */} @@ -40,52 +78,51 @@ export const InvestorProfile: React.FC = () => { src={investor.avatarUrl} alt={investor.name} size="xl" - status={investor.isOnline ? 'online' : 'offline'} + status={userStatuses[investor.id]?.status || "offline"} className="mx-auto sm:mx-0" /> - +
-

{investor.name}

+

+ {investor.name} +

- Investor • {investor.totalInvestments} investments + Investor • {investor.totalInvestments || 0} investments

- +
- San Francisco, CA + {investor.location || "Global"} - {investor.investmentStage.map((stage, index) => ( - {stage} + {investor.investmentStage?.map((stage, index) => ( + + {stage} + ))}
- +
{!isCurrentUser && ( - + )} - + {isCurrentUser && ( - + + + )}
- +
{/* Main content - left side */}
@@ -95,169 +132,102 @@ export const InvestorProfile: React.FC = () => {

About

-

{investor.bio}

+

+ {investor.bio || "No bio provided."} +

- + {/* Investment Interests */} -

Investment Interests

+

+ Investment Interests +

-

Industries

+

+ Industries +

- {investor.investmentInterests.map((interest, index) => ( - {interest} + {investor.investmentInterests?.map((interest, index) => ( + + {interest} + ))}
- +
-

Investment Stages

+

+ Investment Stages +

- {investor.investmentStage.map((stage, index) => ( - {stage} + {investor.investmentStage?.map((stage, index) => ( + + {stage} + ))}
- -
-

Investment Criteria

-
    -
  • - - Strong founding team with domain expertise -
  • -
  • - - Clear market opportunity and product-market fit -
  • -
  • - - Scalable business model with strong unit economics -
  • -
  • - - Potential for significant growth and market impact -
  • -
-
-
-
-
- - {/* Portfolio Companies */} - - -

Portfolio Companies

- {investor.portfolioCompanies.length} companies -
- -
- {investor.portfolioCompanies.map((company, index) => ( -
-
- -
-
-

{company}

-

Invested in 2022

-
-
- ))}
- + {/* Sidebar - right side */}
{/* Investment Details */} -

Investment Details

+

+ Investment Details +

- Investment Range + + Investment Range +

- {investor.minimumInvestment} - {investor.maximumInvestment} + {investor.minimumInvestment || "$0"} -{" "} + {investor.maximumInvestment || "Unlimited"}

- -
- Total Investments -

{investor.totalInvestments} companies

-
- +
- Typical Investment Timeline -

3-5 years

-
- -
- Investment Focus -
-
- SaaS & B2B -
-
-
-
-
- FinTech -
-
-
-
-
- HealthTech -
-
-
-
-
+ + Total Investments + +

+ {investor.totalInvestments || 0} companies +

- + {/* Stats */} -

Investment Stats

+

+ Investment Stats +

-

Successful Exits

-

4

-
- -
-
- -
-
-
-

Avg. ROI

-

3.2x

-
- -
-
- -
-
-
-

Active Investments

-

{investor.portfolioCompanies.length}

+

+ Successful Exits +

+

+ -- +

@@ -269,4 +239,4 @@ export const InvestorProfile: React.FC = () => {
); -}; \ No newline at end of file +}; diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx index bb6d5f17f..7b58fcf54 100644 --- a/src/pages/settings/SettingsPage.tsx +++ b/src/pages/settings/SettingsPage.tsx @@ -1,175 +1,615 @@ -import React from 'react'; -import { User, Lock, Bell, Globe, Palette, CreditCard } from 'lucide-react'; -import { Card, CardHeader, CardBody } from '../../components/ui/Card'; -import { Input } from '../../components/ui/Input'; -import { Button } from '../../components/ui/Button'; -import { Badge } from '../../components/ui/Badge'; -import { Avatar } from '../../components/ui/Avatar'; -import { useAuth } from '../../context/AuthContext'; +import React, { useState } from "react"; +import { + User as UserIcon, + Lock, + Bell, + Building2, + CircleDollarSign, +} from "lucide-react"; +import { Card, CardHeader, CardBody } from "../../components/ui/Card"; +import { Input } from "../../components/ui/Input"; +import { Button } from "../../components/ui/Button"; +import { Badge } from "../../components/ui/Badge"; +import { Avatar } from "../../components/ui/Avatar"; +import { useAuth } from "../../context/AuthContext"; +import toast from "react-hot-toast"; + +type SettingsTab = "profile" | "security" | "role" | "notifications"; export const SettingsPage: React.FC = () => { - const { user } = useAuth(); - + const { user, updateProfile, changePassword } = useAuth(); + const [activeTab, setActiveTab] = useState("profile"); + + // Profile state + const [name, setName] = useState(user?.name || ""); + const [bio, setBio] = useState(user?.bio || ""); + const [location, setLocation] = useState(user?.location || ""); + const [avatar, setAvatar] = useState(user?.avatarUrl || ""); + const [isUpdatingProfile, setIsUpdatingProfile] = useState(false); + const fileInputRef = React.useRef(null); + + // Password state + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isChangingPassword, setIsChangingPassword] = useState(false); + + // Entrepreneur-specific state + const [startupName, setStartupName] = useState(user?.startupName || ""); + const [pitchSummary, setPitchSummary] = useState(user?.pitchSummary || ""); + const [industry, setIndustry] = useState(user?.industry || ""); + const [fundingNeeded, setFundingNeeded] = useState(user?.fundingNeeded || ""); + + // Investor-specific state + const [investmentInterests, setInvestmentInterests] = useState( + user?.investmentInterests?.join(", ") || "", + ); + const [minInvestment, setMinInvestment] = useState( + user?.minimumInvestment || "", + ); + const [maxInvestment, setMaxInvestment] = useState( + user?.maximumInvestment || "", + ); + + // 2FA state + const { setup2FA, enable2FA, disable2FA } = useAuth(); + const [is2FALoading, setIs2FALoading] = useState(false); + const [show2FAVerify, setShow2FAVerify] = useState(false); + const [twoFactorOtp, setTwoFactorOtp] = useState(""); + if (!user) return null; - + + const handleUpdateProfile = async () => { + setIsUpdatingProfile(true); + try { + await updateProfile(user.id, { name, bio, location, avatarUrl: avatar }); + } catch { + // Error handled by context + } finally { + setIsUpdatingProfile(false); + } + }; + + const handlePhotoChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > 800 * 1024) { + toast.error("File size exceeds 800KB limit"); + return; + } + + const reader = new FileReader(); + reader.onloadend = () => { + setAvatar(reader.result as string); + }; + reader.readAsDataURL(file); + }; + + const handleUpdateRoleSettings = async () => { + setIsUpdatingProfile(true); + try { + if (user.role === "entrepreneur") { + await updateProfile(user.id, { + startupName, + pitchSummary, + industry, + fundingNeeded, + }); + } else { + await updateProfile(user.id, { + investmentInterests: investmentInterests + .split(",") + .map((i) => i.trim()), + minimumInvestment: minInvestment, + maximumInvestment: maxInvestment, + }); + } + } catch { + // Error handled by context + } finally { + setIsUpdatingProfile(false); + } + }; + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + if (newPassword !== confirmPassword) { + toast.error("New passwords do not match"); + return; + } + + setIsChangingPassword(true); + console.log("[DEBUG] Changing password for user:", user.id); + try { + await changePassword(currentPassword, newPassword); + console.log("[DEBUG] Password change successful"); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch (error) { + console.error("[DEBUG] Password change failed:", error); + // Error handled by context + } finally { + setIsChangingPassword(false); + } + }; + + const handleSetup2FA = async () => { + setIs2FALoading(true); + try { + await setup2FA(); + setShow2FAVerify(true); + } catch { + // Error handled by context + } finally { + setIs2FALoading(false); + } + }; + + const handleVerify2FA = async () => { + if (!twoFactorOtp) { + toast.error("Please enter the verification code"); + return; + } + setIs2FALoading(true); + try { + await enable2FA(twoFactorOtp); + setShow2FAVerify(false); + setTwoFactorOtp(""); + } catch { + // Error handled by context + } finally { + setIs2FALoading(false); + } + }; + + const handleDisable2FA = async () => { + if ( + !window.confirm( + "Are you sure you want to disable two-factor authentication?", + ) + ) { + return; + } + setIs2FALoading(true); + try { + await disable2FA(); + } catch { + // Error handled by context + } finally { + setIs2FALoading(false); + } + }; + return (

Settings

-

Manage your account preferences and settings

+

+ Manage your account preferences and professional details +

- +
{/* Settings navigation */} - + - + {/* Main settings content */} -
- {/* Profile Settings */} - - -

Profile Settings

-
- -
- - -
- -

- JPG, GIF or PNG. Max size of 800K -

-
-
- -
- - - - - - - -
- -
- - -
- -
- - -
-
-
- - {/* Security Settings */} - - -

Security Settings

-
- -
-

Two-Factor Authentication

-
+
+ {activeTab === "profile" && ( + + +

+ Basic Profile +

+
+ +
+
-

- Add an extra layer of security to your account + + +

+ JPG, GIF or PNG. Max size of 800K

- Not Enabled
-
-
- -
-

Change Password

-
+ +
setName(e.target.value)} /> - + - setLocation(e.target.value)} + /> +
+ +
+ + +
+ +
+ + +
+ + + )} + + {activeTab === "role" && ( + + +

+ {user.role === "entrepreneur" + ? "Startup Details" + : "Investment Preferences"} +

+
+ + {user.role === "entrepreneur" ? ( + <> +
+ setStartupName(e.target.value)} + /> + setIndustry(e.target.value)} + /> + setFundingNeeded(e.target.value)} + /> +
+
+ + +
+ + ) : ( + <> +
+ setMinInvestment(e.target.value)} + /> + setMaxInvestment(e.target.value)} + /> +
+
+ setInvestmentInterests(e.target.value)} + placeholder="e.g. Fintech, AI, Sustanability" + /> +
+ + )} + +
+ +
+
+
+ )} + + {activeTab === "security" && ( + + +

+ Security Settings +

+
+ +
+

+ Change Password +

+ setCurrentPassword(e.target.value)} + required /> - +
+ setNewPassword(e.target.value)} + required + /> + setConfirmPassword(e.target.value)} + required + /> +
- + +
+
+ +
+

+ Two-Factor Authentication +

+
+
+
+

+ Add an extra layer of security to your account +

+ + {user.isTwoFactorEnabled ? "Enabled" : "Not Enabled"} + +
+ {user.isTwoFactorEnabled ? ( + + ) : !show2FAVerify ? ( + + ) : null} +
+ + {show2FAVerify && !user.isTwoFactorEnabled && ( +
+

+ Verify Social Email OTP +

+

+ We've sent a 6-digit code to{" "} + {user.email}. Please enter it below + to complete the setup. +

+
+
+ setTwoFactorOtp(e.target.value)} + maxLength={6} + className="w-full sm:w-32 text-center tracking-[0.5em] font-mono text-xl" + /> +
+
+ + +
+
+
+ )} +
+
+
+
+ )} + + {activeTab === "notifications" && ( + + +

+ Notifications +

+
+ +
+ {[ + { + label: "Email Notifications", + desc: "Receive emails for new messages and requests", + }, + { + label: "Push Notifications", + desc: "Receive browser notifications for activity", + }, + { + label: "Marketing Emails", + desc: "Receive news and product updates", + }, + ].map((item, i) => ( +
+
+

+ {item.label} +

+

{item.desc}

+
+ +
+ ))} +
+
-
- - + + + )}
); -}; \ No newline at end of file +}; diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 000000000..dcbfcf57c --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,20 @@ +import axios from "axios"; + +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3001"; + +const api = axios.create({ + baseURL: API_URL, + withCredentials: true, +}); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem("business_nexus_token"); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; +}); + +export default api; diff --git a/src/services/dashboardService.ts b/src/services/dashboardService.ts new file mode 100644 index 000000000..abe1a3f3e --- /dev/null +++ b/src/services/dashboardService.ts @@ -0,0 +1,21 @@ +import api from "./api"; +import { Meeting } from "../types"; + +export interface DashboardSummary { + pendingRequests: number; + totalConnections: number; + upcomingMeetings: number; + unreadMessages: number; + meetings: Meeting[]; + profileViews?: number; + totalStartups?: number; +} + +export const dashboardService = { + getSummary: async () => { + const response = await api.get("/dashboard/summary"); + return response.data; + }, +}; + +export default dashboardService; diff --git a/src/services/dealService.ts b/src/services/dealService.ts new file mode 100644 index 000000000..11ef2c942 --- /dev/null +++ b/src/services/dealService.ts @@ -0,0 +1,33 @@ +import api from "./api"; +import { Deal } from "../types"; + +export const dealService = { + getDeals: async () => { + const response = await api.get("/deals"); + return response.data; + }, + + createDeal: async (deal: { + entrepreneurId: string; + amount: string; + equity: string; + status?: string; + stage: string; + notes?: string; + }) => { + const response = await api.post("/deals", deal); + return response.data; + }, + + updateDeal: async (id: string, updates: Partial) => { + const response = await api.put(`/deals/${id}`, updates); + return response.data; + }, + + deleteDeal: async (id: string) => { + const response = await api.delete<{ message: string }>(`/deals/${id}`); + return response.data; + }, +}; + +export default dealService; diff --git a/src/services/documentService.ts b/src/services/documentService.ts new file mode 100644 index 000000000..54bba4536 --- /dev/null +++ b/src/services/documentService.ts @@ -0,0 +1,32 @@ +import api from "./api"; +import { Document } from "../types"; + +export const documentService = { + getDocuments: async () => { + const response = await api.get("/documents"); + return response.data; + }, + + uploadDocument: async (doc: { + name: string; + type: string; + size: string; + content: string; + shared?: boolean; + }) => { + const response = await api.post("/documents", doc); + return response.data; + }, + + deleteDocument: async (id: string) => { + const response = await api.delete<{ message: string }>(`/documents/${id}`); + return response.data; + }, + + toggleShare: async (id: string) => { + const response = await api.put(`/documents/${id}/share`); + return response.data; + }, +}; + +export default documentService; diff --git a/src/services/meetingService.ts b/src/services/meetingService.ts new file mode 100644 index 000000000..674db1dbf --- /dev/null +++ b/src/services/meetingService.ts @@ -0,0 +1,33 @@ +import api from "./api"; +import { Meeting } from "../types"; + +export const meetingService = { + getMeetings: async () => { + const response = await api.get("/meetings"); + return response.data; + }, + + scheduleMeeting: async ( + meetingData: Omit, + ) => { + const response = await api.post("/meetings", meetingData); + return response.data; + }, + + updateMeetingStatus: async ( + meetingId: string, + status: "accepted" | "rejected" | "cancelled", + ) => { + const response = await api.put(`/meetings/${meetingId}/status`, { + status, + }); + return response.data; + }, + + getMeetingById: async (meetingId: string) => { + const response = await api.get(`/meetings/${meetingId}`); + return response.data; + }, +}; + +export default meetingService; diff --git a/src/services/messageService.ts b/src/services/messageService.ts new file mode 100644 index 000000000..392ad643c --- /dev/null +++ b/src/services/messageService.ts @@ -0,0 +1,31 @@ +import api from "./api"; +import { Message, ChatConversation } from "../types"; + +export const messageService = { + getConversations: async () => { + const response = await api.get( + "/messages/conversations", + ); + return response.data; + }, + + getMessages: async (partnerId: string) => { + const response = await api.get(`/messages/${partnerId}`); + return response.data; + }, + + sendMessage: async (receiverId: string, content: string) => { + const response = await api.post("/messages", { + receiverId, + content, + }); + return response.data; + }, + + getUnreadCount: async () => { + const response = await api.get<{ count: number }>("/messages/unread-count"); + return response.data.count; + }, +}; + +export default messageService; diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts new file mode 100644 index 000000000..15d2908bb --- /dev/null +++ b/src/services/notificationService.ts @@ -0,0 +1,30 @@ +import api from "./api"; +import { Notification } from "../types"; + +export const notificationService = { + getNotifications: async () => { + const response = await api.get("/notifications"); + return response.data; + }, + + markAsRead: async (id: string) => { + const response = await api.put(`/notifications/${id}/read`); + return response.data; + }, + + markAllAsRead: async () => { + const response = await api.put<{ message: string }>( + "/notifications/read-all", + ); + return response.data; + }, + + deleteNotification: async (id: string) => { + const response = await api.delete<{ message: string }>( + `/notifications/${id}`, + ); + return response.data; + }, +}; + +export default notificationService; diff --git a/src/services/paymentService.ts b/src/services/paymentService.ts new file mode 100644 index 000000000..882915fe1 --- /dev/null +++ b/src/services/paymentService.ts @@ -0,0 +1,68 @@ +import api from "./api"; +import { Transaction } from "../types"; + +export const paymentService = { + getBalance: async () => { + const response = await api.get<{ balance: number }>("/payments/balance"); + return response.data.balance; + }, + + getTransactions: async () => { + const response = await api.get("/payments/transactions"); + return response.data; + }, + + deposit: async (amount: number, method?: string) => { + const response = await api.post<{ + message: string; + balance: number; + transaction: Transaction; + }>("/payments/deposit", { + amount, + method, + }); + return response.data; + }, + + withdraw: async (amount: number, method?: string) => { + const response = await api.post<{ + message: string; + balance: number; + transaction: Transaction; + }>("/payments/withdraw", { + amount, + method, + }); + return response.data; + }, + + transfer: async ( + recipientId: string, + amount: number, + description?: string, + ) => { + const response = await api.post<{ + message: string; + balance: number; + transaction: Transaction; + }>("/payments/transfer", { + recipientId, + amount, + description, + }); + return response.data; + }, + searchUsers: async (query: string) => { + const response = await api.get("/users/search", { + params: { query }, + }); + return response.data; + }, + getConnections: async () => { + const response = + await api.get("/users/connections"); + return response.data; + }, +}; + +export default paymentService; diff --git a/src/services/supportService.ts b/src/services/supportService.ts new file mode 100644 index 000000000..c80766072 --- /dev/null +++ b/src/services/supportService.ts @@ -0,0 +1,33 @@ +import api from "./api"; +import { SupportTicket } from "../types"; + +export const supportService = { + getTickets: async () => { + const response = await api.get("/support/tickets"); + return response.data; + }, + + createTicket: async (ticket: { + name: string; + email: string; + subject?: string; + message: string; + priority?: string; + }) => { + const response = await api.post("/support/tickets", ticket); + return response.data; + }, + + updateTicket: async ( + id: string, + updates: { status?: string; priority?: string }, + ) => { + const response = await api.put( + `/support/tickets/${id}`, + updates, + ); + return response.data; + }, +}; + +export default supportService; diff --git a/src/services/userService.ts b/src/services/userService.ts new file mode 100644 index 000000000..20529f46c --- /dev/null +++ b/src/services/userService.ts @@ -0,0 +1,9 @@ +import api from "./api"; +import { User } from "../types"; + +export const userService = { + searchUsers: async (query: string = "") => { + const response = await api.get(`/users/search?query=${query}`); + return response.data; + }, +}; diff --git a/src/types/index.ts b/src/types/index.ts index 02212bbf9..f085386ae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ -export type UserRole = 'entrepreneur' | 'investor'; +// No imports needed for plain interfaces +export type UserRole = "entrepreneur" | "investor"; export interface User { id: string; @@ -7,12 +8,31 @@ export interface User { role: UserRole; avatarUrl: string; bio: string; + location?: string; isOnline?: boolean; + walletBalance?: number; + isTwoFactorEnabled?: boolean; createdAt: string; + + // Entrepreneur fields + startupName?: string; + pitchSummary?: string; + fundingNeeded?: string; + industry?: string; + foundedYear?: number; + teamSize?: number; + + // Investor fields + investmentInterests?: string[]; + investmentStage?: string[]; + portfolioCompanies?: string[]; + totalInvestments?: number; + minimumInvestment?: string; + maximumInvestment?: string; } export interface Entrepreneur extends User { - role: 'entrepreneur'; + role: "entrepreneur"; startupName: string; pitchSummary: string; fundingNeeded: string; @@ -23,7 +43,7 @@ export interface Entrepreneur extends User { } export interface Investor extends User { - role: 'investor'; + role: "investor"; investmentInterests: string[]; investmentStage: string[]; portfolioCompanies: string[]; @@ -38,6 +58,7 @@ export interface Message { receiverId: string; content: string; timestamp: string; + createdAt: string; isRead: boolean; } @@ -45,6 +66,14 @@ export interface ChatConversation { id: string; participants: string[]; lastMessage?: Message; + partner?: { + id: string; + name: string; + avatarUrl: string; + isOnline: boolean; + }; + lastMessageDate?: string; + unreadCount: number; updatedAt: string; } @@ -53,8 +82,16 @@ export interface CollaborationRequest { investorId: string; entrepreneurId: string; message: string; - status: 'pending' | 'accepted' | 'rejected'; + status: "pending" | "accepted" | "rejected"; createdAt: string; + partner?: { + id: string; + name: string; + email: string; + avatarUrl: string; + role: string; + isOnline?: boolean; + }; } export interface Document { @@ -68,14 +105,108 @@ export interface Document { ownerId: string; } +export interface Meeting { + id: string; + title: string; + description?: string; + investorId: string; + entrepreneurId: string; + startTime: string; + endTime: string; + status: "pending" | "accepted" | "rejected" | "cancelled"; + location?: string; + createdAt: string; +} + +export interface Notification { + id: string; + userId: string; + type: + | "message" + | "collaboration_request" + | "collaboration_accepted" + | "meeting_scheduled" + | "meeting_status"; + title: string; + message: string; + link: string; + isRead: boolean; + createdAt: string; +} + +export interface Document { + id: string; + userId: string; + name: string; + type: string; + size: string; + content: string; + shared: boolean; + createdAt: string; +} + +export interface Deal { + id: string; + investorId: string; + entrepreneurId: string; + amount: string; + equity: string; + status: "Due Diligence" | "Term Sheet" | "Negotiation" | "Closed" | "Passed"; + stage: string; + notes?: string; + createdAt: string; + lastActivity: string; +} + +export interface SupportTicket { + id: string; + userId: string; + name: string; + email: string; + subject: string; + message: string; + status: "open" | "in-progress" | "resolved" | "closed"; + priority: "low" | "medium" | "high"; + createdAt: string; + updatedAt: string; +} + +export interface Transaction { + id: string; + userId: string; + type: "deposit" | "withdraw" | "transfer"; + amount: number; + status: "pending" | "completed" | "failed"; + description: string; + recipientId?: string; + createdAt: string; +} + export interface AuthContextType { user: User | null; - login: (email: string, password: string, role: UserRole) => Promise; - register: (name: string, email: string, password: string, role: UserRole) => Promise; + login: ( + email: string, + password: string, + role: UserRole, + ) => Promise<{ requires2FA?: boolean; tempToken?: string } | void>; + register: ( + name: string, + email: string, + password: string, + role: UserRole, + ) => Promise; logout: () => void; forgotPassword: (email: string) => Promise; resetPassword: (token: string, newPassword: string) => Promise; updateProfile: (userId: string, updates: Partial) => Promise; + changePassword: ( + currentPassword: string, + newPassword: string, + ) => Promise; + setup2FA: () => Promise; + enable2FA: (otp: string) => Promise; + disable2FA: () => Promise; + validate2FALogin: (tempToken: string, otp: string) => Promise; isAuthenticated: boolean; isLoading: boolean; -} \ No newline at end of file +} diff --git a/vercel.json b/vercel.json index 46fe2ba03..2b3aff9c6 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,7 @@ { - "rewrites": [{ "source": "/(.*)", "destination": "/" }] - } - \ No newline at end of file + "version": 2, + "rewrites": [ + { "source": "/api/(.*)", "destination": "http://localhost:3001/api/$1" }, + { "source": "/(.*)", "destination": "/index.html" } + ] +} diff --git a/vite.config.ts b/vite.config.ts index 147380aff..6c3301b30 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,19 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ + server: { + proxy: { + "/api": { + target: "http://localhost:3001", + changeOrigin: true, + secure: false, + }, + }, + }, plugins: [react()], optimizeDeps: { - exclude: ['lucide-react'], + exclude: ["lucide-react"], }, });