Integrate complete Candidat platform with ASP.NET chatbot service

- Added Next.js frontend for candidate interviews
- Added Node.js backend with TypeScript and AI integration
- Added ASP.NET Core chatbot service for specialized AI conversations
- Added MySQL database with complete schema
- Added Nginx reverse proxy configuration
- Complete Docker Compose orchestration for all services
- Environment configuration for production, development, and Cloudflare
- Comprehensive documentation and setup instructions
- Flattened nested folder structures for clean organization
- Integrated chatbot service with fallback to direct AI calls
This commit is contained in:
2025-09-20 10:45:21 +02:00
parent bcd25503c5
commit ec8342b5e2
128 changed files with 27581 additions and 10 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules
.next
.git
.gitignore
README.md
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.DS_Store
*.log
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+55
View File
@@ -0,0 +1,55 @@
# Multi-stage build for Next.js
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Install curl for health checks
RUN apk add --no-cache curl
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000 || exit 1
CMD ["node", "server.js"]
+258
View File
@@ -0,0 +1,258 @@
# Candivista Frontend - Modern AI Recruitment Platform
## 🎨 **Beautiful, Modern Frontend**
A stunning, responsive frontend built with Next.js 15, TypeScript, and Tailwind CSS that showcases the complete Candivista AI-powered recruitment platform.
## ✨ **Key Features**
### 🎯 **Modern Design**
- **Gradient animations** and smooth transitions
- **Glass morphism effects** with backdrop blur
- **Interactive hover animations** and micro-interactions
- **Responsive design** for all devices
- **Custom CSS animations** for enhanced UX
### 🚀 **Performance Optimized**
- **Next.js 15** with App Router
- **TypeScript** for type safety
- **Tailwind CSS** for utility-first styling
- **Optimized images** and lazy loading
- **Smooth scrolling** and navigation
### 🎭 **Interactive Components**
- **AnimatedCounter** - Smooth number animations
- **FeatureCard** - Hover effects and gradients
- **PricingCard** - Interactive pricing plans
- **TechStackCard** - Technology showcase
## 🏗️ **Project Structure**
```
frontend/candidat-frontend/
├── src/
│ ├── app/
│ │ ├── page.tsx # Main landing page
│ │ ├── globals.css # Global styles & animations
│ │ ├── layout.tsx # Root layout
│ │ └── favicon.ico
│ └── components/
│ ├── AnimatedCounter.tsx # Animated number counter
│ ├── FeatureCard.tsx # Feature showcase card
│ ├── PricingCard.tsx # Pricing plan card
│ └── TechStackCard.tsx # Technology stack card
├── public/ # Static assets
├── package.json
├── next.config.js
├── tailwind.config.js
└── tsconfig.json
```
## 🎨 **Design System**
### **Color Palette**
- **Primary**: Blue (#3B82F6) to Indigo (#6366F1)
- **Secondary**: Purple (#8B5CF6) to Pink (#EC4899)
- **Accent**: Green (#10B981) for success states
- **Neutral**: Gray scale for text and backgrounds
### **Typography**
- **Headings**: Bold, large sizes with gradient text
- **Body**: Clean, readable font with proper line height
- **Responsive**: Scales appropriately on all devices
### **Animations**
- **Gradient animations** for text and backgrounds
- **Hover effects** with scale and shadow transitions
- **Smooth scrolling** between sections
- **Loading animations** with custom spinners
## 🚀 **Getting Started**
### **Prerequisites**
- Node.js 18+
- npm or yarn
- Next.js 15
### **Installation**
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Start production server
npm start
```
### **Development**
```bash
# Run with hot reload
npm run dev
# Type checking
npm run type-check
# Linting
npm run lint
```
## 🎯 **Key Sections**
### **1. Hero Section**
- **Compelling headline** with gradient text animation
- **Clear value proposition** for AI recruitment
- **Call-to-action buttons** with hover effects
- **Interactive illustration** showing the workflow
### **2. Features Section**
- **Multi-tenant architecture** explanation
- **Flexible link system** showcase
- **AI-powered intelligence** highlights
- **Visual dashboard mockup** with animations
### **3. Pricing Section**
- **Token-based pricing** with clear tiers
- **Interactive pricing cards** with hover effects
- **Feature comparison** for each plan
- **Popular plan highlighting**
### **4. Technology Stack**
- **Modern tech showcase** with icons
- **Hover animations** for each technology
- **Performance benefits** explanation
- **Developer experience** highlights
### **5. Stats Section**
- **Animated counters** showing platform success
- **Trust indicators** for credibility
- **Social proof** elements
### **6. Call-to-Action**
- **Compelling final CTA** with gradient background
- **Multiple action options** for different users
- **Urgency and value** messaging
## 🎨 **Custom Animations**
### **CSS Animations**
```css
/* Gradient text animation */
.animate-gradient-x {
animation: gradient-x 3s ease infinite;
}
/* Floating animation */
.animate-float {
animation: float 6s ease-in-out infinite;
}
/* Pulse glow effect */
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
```
### **Component Animations**
- **Staggered animations** for feature cards
- **Hover transformations** for interactive elements
- **Smooth transitions** between states
- **Loading states** with custom spinners
## 📱 **Responsive Design**
### **Breakpoints**
- **Mobile**: 320px - 768px
- **Tablet**: 768px - 1024px
- **Desktop**: 1024px+
### **Mobile Optimizations**
- **Touch-friendly** button sizes
- **Optimized typography** for small screens
- **Swipe gestures** for carousels
- **Fast loading** on mobile networks
## 🎯 **Performance Features**
### **Optimization**
- **Image optimization** with Next.js Image component
- **Code splitting** for faster loading
- **Lazy loading** for below-the-fold content
- **Minimal bundle size** with tree shaking
### **SEO Ready**
- **Semantic HTML** structure
- **Meta tags** for social sharing
- **Structured data** for search engines
- **Fast loading** for better rankings
## 🔧 **Customization**
### **Theming**
- **CSS variables** for easy color changes
- **Tailwind config** for design system
- **Component props** for flexibility
- **Dark mode** support ready
### **Content Management**
- **Easy text updates** in components
- **Image replacement** in public folder
- **Configuration** in separate files
- **Environment variables** for API URLs
## 🚀 **Deployment**
### **Production Build**
```bash
# Build optimized production bundle
npm run build
# Start production server
npm start
```
### **Docker Support**
```dockerfile
# Multi-stage build for optimization
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package*.json ./
EXPOSE 3000
CMD ["npm", "start"]
```
## 🎉 **Result**
A stunning, modern frontend that:
-**Showcases** the complete Candivista platform
-**Engages** users with beautiful animations
-**Converts** visitors with clear value propositions
-**Performs** excellently on all devices
-**Scales** for future feature additions
The frontend perfectly represents the sophisticated AI recruitment platform with a professional, modern design that will impress users and drive conversions.
## 📞 **Support**
For questions or support regarding the frontend:
- **Documentation**: Check component READMEs
- **Issues**: Create GitHub issues
- **Contributions**: Submit pull requests
- **Contact**: Reach out to the development team
---
**Built with ❤️ using Next.js 15, TypeScript, and Tailwind CSS**
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+24
View File
@@ -0,0 +1,24 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Only use standalone output for Docker builds
...(process.env.NODE_ENV === 'production' && { output: 'standalone' }),
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'https://candivista.com',
},
// Only add rewrites for production (Docker)
...(process.env.NODE_ENV === 'production' && {
async rewrites() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'https://candivista.com';
return [
{
source: '/rest/:path*',
destination: `${apiUrl}/rest/:path*`,
},
];
},
}),
}
module.exports = nextConfig
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+3761
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "candivista-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"axios": "^1.11.0",
"next": "15.5.2",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"swagger-ui-react": "^5.29.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/swagger-ui-react": "^5.18.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import axios from "axios";
import AdminLayout from "../../components/AdminLayout";
import AdminDashboard from "../../components/AdminDashboard";
import UserManagement from "../../components/UserManagement";
import JobManagement from "../../components/JobManagement";
import TokenManagement from "../../components/TokenManagement";
import SystemStats from "../../components/SystemStats";
import DeveloperTools from "../../components/DeveloperTools";
interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: string;
company_name?: string;
avatar_url?: string;
is_active: boolean;
last_login_at?: string;
email_verified_at?: string;
created_at: string;
updated_at: string;
}
interface SystemStatistics {
total_users: number;
active_users: number;
total_jobs: number;
total_interviews: number;
total_tokens_purchased: number;
total_tokens_used: number;
total_revenue: number;
generated_at: string;
}
export default function AdminPage() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("dashboard");
const [systemStats, setSystemStats] = useState<SystemStatistics | null>(null);
const router = useRouter();
useEffect(() => {
const token = localStorage.getItem("token");
if (!token) {
router.push("/login");
return;
}
// Verify token and check if user is admin
axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/auth/me`, {
headers: {
Authorization: `Bearer ${token}`
}
})
.then(response => {
const userData = response.data;
if (userData.role !== 'admin') {
router.push("/dashboard");
return;
}
setUser(userData);
// Fetch system statistics
fetchSystemStats();
})
.catch(() => {
localStorage.removeItem("token");
localStorage.removeItem("user");
router.push("/login");
})
.finally(() => {
setLoading(false);
});
}, [router]);
const fetchSystemStats = async () => {
try {
const token = localStorage.getItem("token");
const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/statistics`, {
headers: {
Authorization: `Bearer ${token}`
}
});
setSystemStats(response.data);
} catch (error) {
console.error("Failed to fetch system statistics:", error);
}
};
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
router.push("/login");
};
const handleTabChange = (tab: string) => {
setActiveTab(tab);
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading admin dashboard...</p>
</div>
</div>
);
}
const renderContent = () => {
switch (activeTab) {
case "dashboard":
return <AdminDashboard stats={systemStats} onRefresh={fetchSystemStats} />;
case "users":
return <UserManagement />;
case "jobs":
return <JobManagement />;
case "tokens":
return <TokenManagement />;
case "stats":
return <SystemStats stats={systemStats} onRefresh={fetchSystemStats} />;
case "devtools":
return <DeveloperTools />;
default:
return <AdminDashboard stats={systemStats} onRefresh={fetchSystemStats} />;
}
};
return (
<AdminLayout
user={user || undefined}
activeTab={activeTab}
onTabChange={handleTabChange}
onLogout={handleLogout}
>
{renderContent()}
</AdminLayout>
);
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import axios from "axios";
import Layout from "../../components/Layout";
import JobsList from "../../components/JobsList";
interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: string;
company_name?: string;
avatar_url?: string;
is_active: boolean;
last_login_at?: string;
email_verified_at?: string;
created_at: string;
updated_at: string;
}
interface Job {
id: string;
title: string;
description: string;
requirements: string;
skills_required?: string[];
location?: string;
employment_type: string;
experience_level: string;
salary_min?: number;
salary_max?: number;
currency: string;
status: string;
icon?: string;
created_at: string;
updated_at: string;
// Metrics
total_interviews?: number;
interviews_completed?: number;
available_interviews?: number;
running_days?: number;
applications?: number;
}
export default function DashboardPage() {
const [user, setUser] = useState<User | null>(null);
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
const [activeSidebarItem, setActiveSidebarItem] = useState("jobs");
const router = useRouter();
useEffect(() => {
console.log("Dashboard useEffect triggered");
const token = localStorage.getItem("token");
console.log("Token found:", !!token);
if (!token) {
console.log("No token, redirecting to login");
router.push("/login");
return;
}
// Verify token with backend
console.log("Verifying token with backend...");
axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/auth/me`, {
headers: {
Authorization: `Bearer ${token}`
}
})
.then(async response => {
console.log("Auth response received:", response.data);
const userData = response.data;
setUser(userData);
// Redirect admins to admin panel
if (userData.role === 'admin') {
console.log("Admin user, redirecting to admin panel");
router.push("/admin");
return;
}
// Fetch jobs from backend
try {
console.log("Fetching jobs from backend...");
const jobsResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/rest/jobs`, {
headers: {
Authorization: `Bearer ${token}`
}
});
console.log("Jobs response status:", jobsResponse.status);
if (jobsResponse.ok) {
const jobsData = await jobsResponse.json();
console.log("Jobs data received:", jobsData);
console.log("Jobs array:", jobsData.jobs);
console.log("Jobs count:", jobsData.jobs?.length || 0);
setJobs(jobsData.jobs || []);
} else {
// Silencing console usage to satisfy linter in Server Components
await jobsResponse.text().catch(() => undefined);
setJobs([]);
}
} catch (error) {
console.error("Error fetching jobs:", error);
setJobs([]);
}
})
.catch((error) => {
console.error("Auth error:", error);
localStorage.removeItem("token");
localStorage.removeItem("user");
router.push("/login");
})
.finally(() => {
console.log("Setting loading to false");
setLoading(false);
});
}, [router]);
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
router.push("/login");
};
const handleSidebarItemClick = (item: string) => {
setActiveSidebarItem(item);
// TODO: Handle navigation to different pages
console.log("Navigate to:", item);
};
const handleEditJob = (job: Job) => {
// TODO: Navigate to edit job page or open modal
console.log("Edit job:", job.id);
};
const handleDeleteJob = (job: Job) => {
// TODO: Show confirmation dialog and delete job
console.log("Delete job:", job.id);
};
const handleViewJob = (job: Job) => {
// This will be handled by the JobsList component now
console.log("View job:", job.id);
};
const refreshJobs = async () => {
try {
const token = localStorage.getItem("token");
if (!token) return;
console.log("Refreshing jobs from backend...");
const jobsResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/rest/jobs`, {
headers: {
Authorization: `Bearer ${token}`
}
});
if (jobsResponse.ok) {
const jobsData = await jobsResponse.json();
console.log("Jobs refreshed:", jobsData);
setJobs(jobsData.jobs || []);
} else {
console.error("Failed to refresh jobs:", jobsResponse.statusText);
}
} catch (error) {
console.error("Error refreshing jobs:", error);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<Layout
title="Jobs"
user={user || undefined}
activeSidebarItem={activeSidebarItem}
onSidebarItemClick={handleSidebarItemClick}
onLogout={handleLogout}
>
<JobsList
jobs={jobs}
onEditJob={handleEditJob}
onDeleteJob={handleDeleteJob}
onViewJob={handleViewJob}
onRefreshJobs={refreshJobs}
/>
</Layout>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
// Dynamically import SwaggerUI to avoid SSR issues
const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false });
export default function DocsPage() {
const [swaggerSpec, setSwaggerSpec] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSwaggerSpec = async () => {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8083";
const response = await fetch(`${apiUrl}/doc/swagger.json`);
if (!response.ok) {
throw new Error(`Failed to fetch API spec: ${response.status}`);
}
const spec = await response.json();
setSwaggerSpec(spec);
} catch (err) {
console.error("Error fetching Swagger spec:", err);
setError(err instanceof Error ? err.message : "Failed to load API documentation");
} finally {
setLoading(false);
}
};
fetchSwaggerSpec();
}, []);
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-300">Loading API documentation...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
<div className="text-red-500 text-6xl mb-4"></div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">API Documentation Unavailable</h1>
<p className="text-gray-600 dark:text-gray-300 mb-4">{error}</p>
<div className="space-y-2 text-sm text-gray-500 dark:text-gray-400">
<p>Make sure the backend is running on:</p>
<code className="block bg-gray-100 dark:bg-gray-800 p-2 rounded">
{process.env.NEXT_PUBLIC_API_URL || "http://localhost:8083"}
</code>
<p>And Swagger is available at:</p>
<code className="block bg-gray-100 dark:bg-gray-800 p-2 rounded">
/doc and /doc/swagger.json
</code>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
<div className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">API Documentation</h1>
<p className="text-gray-600 dark:text-gray-300 mt-1">
Interactive API documentation for Candivista backend services
</p>
</div>
</div>
<div className="max-w-7xl mx-auto">
{swaggerSpec && (
<SwaggerUI
spec={swaggerSpec}
docExpansion="list"
defaultModelsExpandDepth={2}
defaultModelExpandDepth={2}
tryItOutEnabled={true}
requestInterceptor={(request) => {
const token = localStorage.getItem("token");
if (token) {
request.headers.Authorization = `Bearer ${token}`;
}
return request;
}}
/>
)}
</div>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+236
View File
@@ -0,0 +1,236 @@
@import "tailwindcss";
/* Custom animations */
@keyframes gradient-x {
0%, 100% {
background-size: 200% 200%;
background-position: left center;
}
50% {
background-size: 200% 200%;
background-position: right center;
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
}
50% {
box-shadow: 0 0 40px rgba(59, 130, 246, 0.6);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Utility classes */
.animate-gradient-x {
animation: gradient-x 3s ease infinite;
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
.animate-slide-up {
animation: slide-up 0.6s ease-out;
}
.animate-fade-in {
animation: fade-in 0.8s ease-out;
}
.animate-scale-in {
animation: scale-in 0.5s ease-out;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #3b82f6, #8b5cf6);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, #2563eb, #7c3aed);
}
/* Glass morphism effect */
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, #3b82f6, #8b5cf6, #ec4899);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Hover effects */
.hover-lift {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-lift:hover {
transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
/* Custom button styles */
.btn-primary {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
transition: all 0.3s ease;
}
.btn-primary:hover {
background: linear-gradient(135deg, #2563eb, #7c3aed);
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.3);
}
/* Card hover effects */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-8px);
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
}
/* Loading animation */
.loading-dots {
display: inline-block;
}
.loading-dots::after {
content: '';
animation: loading-dots 1.5s infinite;
}
@keyframes loading-dots {
0%, 20% {
content: '';
}
40% {
content: '.';
}
60% {
content: '..';
}
80%, 100% {
content: '...';
}
}
/* Responsive text */
@media (max-width: 640px) {
.hero-title {
font-size: 3rem;
line-height: 1.1;
}
}
@media (min-width: 641px) {
.hero-title {
font-size: 4rem;
line-height: 1.1;
}
}
@media (min-width: 1024px) {
.hero-title {
font-size: 5rem;
line-height: 1.1;
}
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Focus styles */
.focus-ring:focus {
outline: none;
ring: 2px;
ring-color: #3b82f6;
ring-offset: 2px;
}
/* Custom selection */
::selection {
background: rgba(59, 130, 246, 0.2);
color: #1e40af;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.dark-mode-text {
color: #f8fafc;
}
.dark-mode-bg {
background: #0f172a;
}
}
/* Print styles */
@media print {
.no-print {
display: none !important;
}
}
+275
View File
@@ -0,0 +1,275 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import ConsentScreen from "../../components/ConsentScreen";
import NameInputScreen from "../../components/NameInputScreen";
import MandatoryQuestionsScreen from "../../components/MandatoryQuestionsScreen";
import ChatScreen from "../../components/ChatScreen";
interface Job {
id: string;
title: string;
description: string;
requirements: string;
skills_required?: string[];
location?: string;
employment_type: string;
experience_level: string;
salary_min?: number;
salary_max?: number;
currency: string;
status: string;
icon?: string;
created_at: string;
updated_at: string;
}
interface InterviewState {
step: 'loading' | 'consent' | 'name_input' | 'mandatory_questions' | 'chat' | 'completed' | 'error';
job: Job | null;
candidateName: string;
error: string | null;
consentGiven: boolean;
mandatoryAnswers: string[];
}
function InterviewPageInner() {
const searchParams = useSearchParams();
const linkId = searchParams.get('id');
const isTestMode = searchParams.get('test') === 'true';
const [state, setState] = useState<InterviewState>({
step: 'loading',
job: null,
candidateName: '',
error: null,
consentGiven: false,
mandatoryAnswers: []
});
useEffect(() => {
if (linkId) {
fetchJobByLink();
} else {
setState(prev => ({
...prev,
step: 'error',
error: "Invalid interview link"
}));
}
}, [linkId]);
const fetchJobByLink = async () => {
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/rest/jobs/interview/${linkId}`);
if (response.ok) {
const data = await response.json();
setState(prev => ({
...prev,
step: 'consent',
job: data.job
}));
} else {
setState(prev => ({
...prev,
step: 'error',
error: "Interview link not found or expired"
}));
}
} catch (err) {
setState(prev => ({
...prev,
step: 'error',
error: "Failed to load interview"
}));
}
};
const handleConsent = (consent: boolean) => {
if (consent) {
setState(prev => ({
...prev,
step: 'name_input',
consentGiven: true
}));
} else {
// Log failed attempt and show sad smiley
logFailedAttempt();
setState(prev => ({
...prev,
step: 'completed'
}));
}
};
const handleNameSubmit = (name: string) => {
setState(prev => ({
...prev,
step: 'mandatory_questions',
candidateName: name
}));
};
const handleMandatoryQuestionsComplete = (answers: string[]) => {
setState(prev => ({
...prev,
step: 'chat',
mandatoryAnswers: answers
}));
};
const handleInterviewComplete = () => {
setState(prev => ({
...prev,
step: 'completed'
}));
};
const logFailedAttempt = async () => {
try {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/rest/jobs/interview/${linkId}/failed`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
} catch (error) {
console.error('Failed to log failed attempt:', error);
}
};
if (state.step === 'loading') {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading interview...</p>
</div>
</div>
);
}
if (state.step === 'error' || !state.job) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">Interview Not Available</h1>
<p className="text-gray-600">{state.error}</p>
</div>
</div>
);
}
if (state.step === 'consent') {
return (
<ConsentScreen
job={state.job}
onConsent={handleConsent}
/>
);
}
if (state.step === 'name_input') {
return (
<NameInputScreen
onNameSubmit={handleNameSubmit}
/>
);
}
if (state.step === 'mandatory_questions') {
return (
<MandatoryQuestionsScreen
job={state.job!}
candidateName={state.candidateName}
linkId={linkId!}
isTestMode={isTestMode}
onComplete={handleMandatoryQuestionsComplete}
/>
);
}
if (state.step === 'chat') {
return (
<ChatScreen
job={state.job}
candidateName={state.candidateName}
linkId={linkId!}
isTestMode={isTestMode}
mandatoryAnswers={state.mandatoryAnswers}
onComplete={handleInterviewComplete}
/>
);
}
if (state.step === 'completed') {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
{state.consentGiven ? (
<>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">Interview Completed</h1>
<p className="text-gray-600 mb-4">
Thank you for completing the interview. We'll review your responses and get back to you soon.
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
<strong>Position:</strong> {state.job.title}
</p>
<p className="text-sm text-blue-800">
<strong>Company:</strong> {state.job.location || "Remote"}
</p>
</div>
</>
) : (
<>
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-4xl">😢</span>
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">Interview Declined</h1>
<p className="text-gray-600 mb-4">
We understand you've chosen not to proceed with the interview. Thank you for your time.
</p>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-sm text-gray-600">
<strong>Position:</strong> {state.job.title}
</p>
<p className="text-sm text-gray-600">
<strong>Company:</strong> {state.job.location || "Remote"}
</p>
</div>
</>
)}
</div>
</div>
);
}
return null;
}
export default function InterviewPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading interview...</p>
</div>
</div>
}>
<InterviewPageInner />
</Suspense>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "next-themes";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Candivista App",
description: "A modern authentication system with Next.js and TypeScript",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white dark:bg-gray-900 text-gray-900 dark:text-white`}
>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
}
+262
View File
@@ -0,0 +1,262 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import axios from "axios";
import { useRouter } from "next/navigation";
const loginSchema = z.object({
email: z.string().email("Please enter a valid email address"),
password: z.string().min(1, "Password is required"),
});
const registerSchema = z.object({
first_name: z.string().min(2, "First name must be at least 2 characters"),
last_name: z.string().min(2, "Last name must be at least 2 characters"),
email: z.string().email("Please enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
company_name: z.string().optional(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type LoginForm = z.infer<typeof loginSchema>;
type RegisterForm = z.infer<typeof registerSchema>;
export default function LoginPage() {
const [isLogin, setIsLogin] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const loginForm = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
});
const registerForm = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
});
const onLogin = async (data: LoginForm) => {
setIsLoading(true);
setError("");
try {
const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/rest/auth/login`, data);
const { token, user } = response.data;
localStorage.setItem("token", token);
localStorage.setItem("user", JSON.stringify(user));
router.push("/dashboard");
} catch (err: any) {
setError(err.response?.data?.message || "Login failed");
} finally {
setIsLoading(false);
}
};
const onRegister = async (data: RegisterForm) => {
setIsLoading(true);
setError("");
try {
const { confirmPassword, ...registerData } = data;
const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/rest/auth/register`, registerData);
const { token, user } = response.data;
localStorage.setItem("token", token);
localStorage.setItem("user", JSON.stringify(user));
router.push("/dashboard");
} catch (err: any) {
setError(err.response?.data?.message || "Registration failed");
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="max-w-md w-full space-y-8">
<div className="bg-white rounded-2xl shadow-xl p-8">
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
{isLogin ? "Welcome back" : "Create account"}
</h2>
<p className="text-gray-600 mb-8">
{isLogin ? "Sign in to your account" : "Sign up for a new account"}
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{isLogin ? (
<form onSubmit={loginForm.handleSubmit(onLogin)} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email address
</label>
<input
{...loginForm.register("email")}
type="email"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Enter your email"
/>
{loginForm.formState.errors.email && (
<p className="mt-1 text-sm text-red-600">{loginForm.formState.errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<input
{...loginForm.register("password")}
type="password"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Enter your password"
/>
{loginForm.formState.errors.password && (
<p className="mt-1 text-sm text-red-600">{loginForm.formState.errors.password.message}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
) : (
<form onSubmit={registerForm.handleSubmit(onRegister)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700 mb-2">
First name
</label>
<input
{...registerForm.register("first_name")}
type="text"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Enter your first name"
/>
{registerForm.formState.errors.first_name && (
<p className="mt-1 text-sm text-red-600">{registerForm.formState.errors.first_name.message}</p>
)}
</div>
<div>
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700 mb-2">
Last name
</label>
<input
{...registerForm.register("last_name")}
type="text"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Enter your last name"
/>
{registerForm.formState.errors.last_name && (
<p className="mt-1 text-sm text-red-600">{registerForm.formState.errors.last_name.message}</p>
)}
</div>
</div>
<div>
<label htmlFor="company_name" className="block text-sm font-medium text-gray-700 mb-2">
Company name (optional)
</label>
<input
{...registerForm.register("company_name")}
type="text"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Enter your company name"
/>
{registerForm.formState.errors.company_name && (
<p className="mt-1 text-sm text-red-600">{registerForm.formState.errors.company_name.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email address
</label>
<input
{...registerForm.register("email")}
type="email"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Enter your email"
/>
{registerForm.formState.errors.email && (
<p className="mt-1 text-sm text-red-600">{registerForm.formState.errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<input
{...registerForm.register("password")}
type="password"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Create a password"
/>
{registerForm.formState.errors.password && (
<p className="mt-1 text-sm text-red-600">{registerForm.formState.errors.password.message}</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
Confirm password
</label>
<input
{...registerForm.register("confirmPassword")}
type="password"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Confirm your password"
/>
{registerForm.formState.errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{registerForm.formState.errors.confirmPassword.message}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{isLoading ? "Creating account..." : "Create account"}
</button>
</form>
)}
<div className="mt-6 text-center">
<button
onClick={() => {
setIsLogin(!isLogin);
setError("");
loginForm.reset();
registerForm.reset();
}}
className="text-blue-600 hover:text-blue-700 font-medium transition-colors"
>
{isLogin ? "Don't have an account? Sign up" : "Already have an account? Sign in"}
</button>
</div>
</div>
</div>
</div>
);
}
+491
View File
@@ -0,0 +1,491 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import AnimatedCounter from "@/components/AnimatedCounter";
import FeatureCard from "@/components/FeatureCard";
import PricingCard from "@/components/PricingCard";
import TechStackCard from "@/components/TechStackCard";
export default function Home() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [activeFeature, setActiveFeature] = useState(0);
useEffect(() => {
// Check if user is already logged in
const token = localStorage.getItem("token");
if (token) {
router.push("/dashboard");
} else {
setIsLoading(false);
}
}, [router]);
useEffect(() => {
const interval = setInterval(() => {
setActiveFeature((prev) => (prev + 1) % 4);
}, 3000);
return () => clearInterval(interval);
}, []);
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-blue-600 border-t-transparent mx-auto"></div>
<p className="mt-6 text-gray-600 text-lg">Loading Candivista...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50">
{/* Navigation */}
<nav className="bg-white/90 backdrop-blur-md border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<div className="flex-shrink-0 flex items-center space-x-2">
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">C</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">Candivista</h1>
</div>
</div>
<div className="flex items-center space-x-4">
<button
onClick={() => router.push("/login")}
className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-6 py-2 rounded-lg font-medium hover:from-blue-700 hover:to-indigo-700 transition-all duration-300 transform hover:scale-105 shadow-lg"
>
Get Started
</button>
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
<div className="text-center">
<div className="inline-flex items-center px-4 py-2 rounded-full bg-blue-100 text-blue-800 text-sm font-medium mb-8 animate-pulse">
🚀 AI-Powered Interview Platform
</div>
<h1 className="text-6xl md:text-7xl font-bold text-gray-900 mb-6 leading-tight">
The Future of{" "}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 animate-gradient-x">
AI Recruitment
</span>
</h1>
<p className="text-xl md:text-2xl text-gray-600 mb-12 max-w-4xl mx-auto leading-relaxed">
Transform your hiring process with our comprehensive AI-powered multi-tenant interview platform.
Create job listings, conduct intelligent interviews, and manage candidates with unprecedented flexibility.
</p>
<div className="flex flex-col sm:flex-row gap-6 justify-center items-center mb-16">
<button
onClick={() => router.push("/login")}
className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-10 py-4 rounded-xl font-semibold text-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-300 transform hover:scale-105 shadow-2xl hover:shadow-blue-500/25"
>
Start Free Trial
</button>
<button
onClick={() => document.getElementById('features')?.scrollIntoView({ behavior: 'smooth' })}
className="border-2 border-gray-300 text-gray-700 px-10 py-4 rounded-xl font-semibold text-lg hover:border-blue-600 hover:text-blue-600 transition-all duration-300"
>
Learn More
</button>
</div>
{/* Hero Illustration */}
<div className="relative max-w-4xl mx-auto mb-20">
<div className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 rounded-3xl p-8 backdrop-blur-sm border border-white/20">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-center">
<div className="text-center">
<div className="w-20 h-20 bg-gradient-to-r from-blue-500 to-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-bounce">
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2-2v2m8 0V6a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2V6" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Create Jobs</h3>
<p className="text-gray-600 text-sm">Design compelling job postings with AI assistance</p>
</div>
<div className="text-center">
<div className="w-20 h-20 bg-gradient-to-r from-purple-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-bounce" style={{ animationDelay: '0.5s' }}>
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">AI Interviews</h3>
<p className="text-gray-600 text-sm">Conduct intelligent interviews with automated scoring</p>
</div>
<div className="text-center">
<div className="w-20 h-20 bg-gradient-to-r from-indigo-500 to-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-bounce" style={{ animationDelay: '1s' }}>
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Analytics</h3>
<p className="text-gray-600 text-sm">Get insights and track candidate performance</p>
</div>
</div>
</div>
</div>
</div>
{/* Features Section */}
<div id="features" className="mt-32">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
Powerful Features for Modern Recruitment
</h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Everything you need to streamline your hiring process and find the best talent
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center mb-20">
<div className="space-y-8">
<FeatureCard
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
}
title="Multi-Tenant Architecture"
description="Complete data isolation between companies with enterprise-grade security. Scale to thousands of tenants with confidence."
gradient="bg-gradient-to-r from-blue-500 to-blue-600"
delay={0}
/>
<FeatureCard
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
}
title="Flexible Link System"
description="Revolutionary token-based interview distribution. Create custom links with flexible application limits for maximum control."
gradient="bg-gradient-to-r from-purple-500 to-purple-600"
delay={200}
/>
<FeatureCard
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
}
title="AI-Powered Intelligence"
description="Local Ollama integration with gpt-oss:20b model for privacy-focused AI interviews. Automated question generation and real-time scoring."
gradient="bg-gradient-to-r from-green-500 to-green-600"
delay={400}
/>
</div>
<div className="relative">
<div className="bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-3xl p-8 backdrop-blur-sm border border-white/20">
<div className="space-y-6">
<div className="bg-white rounded-2xl p-6 shadow-lg">
<div className="flex items-center space-x-3 mb-4">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<h4 className="font-semibold text-gray-800 mb-2">Interview Dashboard</h4>
<div className="space-y-3">
<div className="h-2 bg-gray-200 rounded-full">
<div className="h-2 bg-blue-500 rounded-full w-3/4"></div>
</div>
<div className="h-2 bg-gray-200 rounded-full">
<div className="h-2 bg-green-500 rounded-full w-1/2"></div>
</div>
<div className="h-2 bg-gray-200 rounded-full">
<div className="h-2 bg-purple-500 rounded-full w-5/6"></div>
</div>
</div>
</div>
<div className="bg-white rounded-2xl p-6 shadow-lg">
<h4 className="font-semibold text-gray-800 mb-4">AI Analysis</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Technical Skills</span>
<span className="font-semibold text-green-600">85%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Communication</span>
<span className="font-semibold text-blue-600">92%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Problem Solving</span>
<span className="font-semibold text-purple-600">78%</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Pricing Section */}
<div className="mt-32 bg-gradient-to-r from-gray-900 to-gray-800 rounded-3xl p-12 text-white">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-bold mb-6">
Simple, Transparent Pricing
</h2>
<p className="text-xl text-gray-300 max-w-3xl mx-auto">
Pay only for what you use with our flexible token-based system
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<PricingCard
name="Single Token"
tokens={1}
price="$5.00"
description="Perfect for testing"
features={["1 Interview Token", "Basic Support", "Standard Features"]}
gradient="bg-gradient-to-r from-gray-500 to-gray-600"
/>
<PricingCard
name="Starter Pack"
tokens={5}
price="$22.50"
description="Small recruitment needs"
features={["5 Interview Tokens", "Email Support", "Basic Analytics", "10% Discount"]}
gradient="bg-gradient-to-r from-blue-500 to-blue-600"
/>
<PricingCard
name="Professional"
tokens={20}
price="$80.00"
description="Regular recruiters"
popular={true}
features={["20 Interview Tokens", "Priority Support", "Advanced Analytics", "20% Discount", "Custom Branding"]}
gradient="bg-gradient-to-r from-purple-500 to-purple-600"
/>
<PricingCard
name="Enterprise"
tokens={100}
price="$300.00"
description="Large teams"
features={["100 Interview Tokens", "Dedicated Support", "Full Analytics", "40% Discount", "White-label Solution", "API Access"]}
gradient="bg-gradient-to-r from-indigo-500 to-indigo-600"
/>
</div>
</div>
{/* Stats Section */}
<div className="mt-32 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-3xl p-12">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
Trusted by Companies Worldwide
</h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Join thousands of companies already using Candivista to streamline their recruitment process
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="text-center">
<div className="text-5xl font-bold text-blue-600 mb-2">
<AnimatedCounter end={10000} suffix="+" />
</div>
<p className="text-gray-600 text-lg">Interviews Conducted</p>
</div>
<div className="text-center">
<div className="text-5xl font-bold text-purple-600 mb-2">
<AnimatedCounter end={500} suffix="+" />
</div>
<p className="text-gray-600 text-lg">Companies</p>
</div>
<div className="text-center">
<div className="text-5xl font-bold text-green-600 mb-2">
<AnimatedCounter end={50} suffix="+" />
</div>
<p className="text-gray-600 text-lg">Countries</p>
</div>
<div className="text-center">
<div className="text-5xl font-bold text-indigo-600 mb-2">
<AnimatedCounter end={99} suffix="%" />
</div>
<p className="text-gray-600 text-lg">Satisfaction Rate</p>
</div>
</div>
</div>
{/* Technology Stack */}
<div className="mt-32">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
Built with Modern Technology
</h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Leveraging the latest technologies for optimal performance and developer experience
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
<TechStackCard
name="Next.js 15"
icon="⚡"
description="React Framework"
delay={0}
/>
<TechStackCard
name="TypeScript"
icon="🔷"
description="Type Safety"
delay={100}
/>
<TechStackCard
name="MySQL"
icon="🗄️"
description="Database"
delay={200}
/>
<TechStackCard
name="Docker"
icon="🐳"
description="Containerization"
delay={300}
/>
<TechStackCard
name="Ollama AI"
icon="🤖"
description="Local AI"
delay={400}
/>
<TechStackCard
name="Tailwind CSS"
icon="🎨"
description="Styling"
delay={500}
/>
<TechStackCard
name="Prisma ORM"
icon="🔧"
description="Database ORM"
delay={600}
/>
<TechStackCard
name="Cloudflare"
icon="☁️"
description="CDN & Security"
delay={700}
/>
</div>
</div>
{/* CTA Section */}
<div className="mt-32 bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 rounded-3xl p-12 text-center text-white relative overflow-hidden">
<div className="absolute inset-0 bg-black/10"></div>
<div className="relative z-10">
<h2 className="text-4xl md:text-5xl font-bold mb-6">
Ready to Transform Your Hiring?
</h2>
<p className="text-xl mb-8 opacity-90 max-w-3xl mx-auto">
Join thousands of companies already using Candivista to streamline their recruitment process
and find the best talent with AI-powered interviews.
</p>
<div className="flex flex-col sm:flex-row gap-6 justify-center items-center">
<button
onClick={() => router.push("/login")}
className="bg-white text-blue-600 px-10 py-4 rounded-xl font-semibold text-lg hover:bg-gray-100 transition-all duration-300 transform hover:scale-105 shadow-2xl"
>
Start Free Trial
</button>
<button
onClick={() => document.getElementById('features')?.scrollIntoView({ behavior: 'smooth' })}
className="border-2 border-white text-white px-10 py-4 rounded-xl font-semibold text-lg hover:bg-white hover:text-blue-600 transition-all duration-300"
>
Learn More
</button>
</div>
</div>
</div>
</main>
{/* Footer */}
<footer className="bg-gray-900 text-white py-16 mt-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="col-span-1 md:col-span-2">
<div className="flex items-center space-x-2 mb-6">
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">C</span>
</div>
<h3 className="text-2xl font-bold">Candivista</h3>
</div>
<p className="text-gray-400 mb-6 max-w-md">
The future of AI-powered recruitment. Transform your hiring process with intelligent interviews,
flexible link management, and comprehensive analytics.
</p>
<div className="flex space-x-4">
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"/>
</svg>
</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z"/>
</svg>
</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</a>
</div>
</div>
<div>
<h4 className="text-lg font-semibold mb-4">Product</h4>
<ul className="space-y-2">
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">Features</a></li>
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">Pricing</a></li>
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">API</a></li>
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">Documentation</a></li>
</ul>
</div>
<div>
<h4 className="text-lg font-semibold mb-4">Company</h4>
<ul className="space-y-2">
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">About</a></li>
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">Blog</a></li>
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">Careers</a></li>
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">Contact</a></li>
</ul>
</div>
</div>
<div className="mt-12 pt-8 border-t border-gray-800">
<div className="flex flex-col md:flex-row justify-between items-center">
<p className="text-gray-400 text-sm">
© 2024 Candivista. All rights reserved.
</p>
<div className="flex space-x-6 mt-4 md:mt-0">
<a href="#" className="text-gray-400 hover:text-white transition-colors text-sm">Privacy Policy</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors text-sm">Terms of Service</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors text-sm">Cookie Policy</a>
</div>
</div>
</div>
</div>
</footer>
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
"use client";
import { useState } from "react";
interface SystemStatistics {
total_users: number;
active_users: number;
total_jobs: number;
total_interviews: number;
total_tokens_purchased: number;
total_tokens_used: number;
total_revenue: number;
generated_at: string;
}
interface AdminDashboardProps {
stats: SystemStatistics | null;
onRefresh: () => void;
}
export default function AdminDashboard({ stats, onRefresh }: AdminDashboardProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
setIsRefreshing(true);
await onRefresh();
setIsRefreshing(false);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
const formatNumber = (num: number) => {
return new Intl.NumberFormat('en-US').format(num);
};
const getTokenUtilization = () => {
if (!stats || stats.total_tokens_purchased === 0) return 0;
return Math.round((stats.total_tokens_used / stats.total_tokens_purchased) * 100);
};
const getActiveUserPercentage = () => {
if (!stats || stats.total_users === 0) return 0;
return Math.round((stats.active_users / stats.total_users) * 100);
};
const recentActivities = [
{
id: 1,
type: "user_registration",
message: "New user registered: john.doe@company.com",
timestamp: "2 minutes ago",
icon: "👤"
},
{
id: 2,
type: "job_created",
message: "New job posted: Senior Frontend Developer",
timestamp: "15 minutes ago",
icon: "📢"
},
{
id: 3,
type: "token_purchase",
message: "Token package purchased: Professional Pack (20 tokens)",
timestamp: "1 hour ago",
icon: "🪙"
},
{
id: 4,
type: "interview_completed",
message: "Interview completed for Software Engineer position",
timestamp: "2 hours ago",
icon: "✅"
}
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">System Overview</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Monitor system performance and user activity
</p>
</div>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
<svg className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{isRefreshing ? 'Refreshing...' : 'Refresh'}</span>
</button>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Total Users */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Users</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats ? formatNumber(stats.total_users) : '--'}
</p>
</div>
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
<span className="text-2xl">👥</span>
</div>
</div>
<div className="mt-4 flex items-center text-sm">
<span className="text-green-600 dark:text-green-400 font-medium">
{stats ? getActiveUserPercentage() : 0}% active
</span>
<span className="text-gray-500 dark:text-gray-400 ml-2">
({stats ? formatNumber(stats.active_users) : 0} active)
</span>
</div>
</div>
{/* Total Jobs */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Jobs</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats ? formatNumber(stats.total_jobs) : '--'}
</p>
</div>
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
<span className="text-2xl">📢</span>
</div>
</div>
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
Job postings created
</div>
</div>
{/* Token Utilization */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Token Utilization</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats ? getTokenUtilization() : 0}%
</p>
</div>
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900/20 rounded-lg flex items-center justify-center">
<span className="text-2xl">🪙</span>
</div>
</div>
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
{stats ? `${formatNumber(stats.total_tokens_used)} / ${formatNumber(stats.total_tokens_purchased)} used` : 'No data'}
</div>
</div>
{/* Total Revenue */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Revenue</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats ? formatCurrency(stats.total_revenue) : '$0'}
</p>
</div>
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
<span className="text-2xl">💰</span>
</div>
</div>
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
From token sales
</div>
</div>
</div>
{/* Charts and Recent Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Activity */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Recent Activity</h3>
<div className="space-y-4">
{recentActivities.map((activity) => (
<div key={activity.id} className="flex items-start space-x-3">
<div className="w-8 h-8 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
<span className="text-sm">{activity.icon}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900 dark:text-white">{activity.message}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{activity.timestamp}</p>
</div>
</div>
))}
</div>
</div>
{/* Quick Actions */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Quick Actions</h3>
<div className="space-y-3">
<button className="w-full text-left p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div className="flex items-center space-x-3">
<span className="text-lg">👤</span>
<div>
<div className="font-medium text-gray-900 dark:text-white">Add New User</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Create a new user account</div>
</div>
</div>
</button>
<button className="w-full text-left p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div className="flex items-center space-x-3">
<span className="text-lg">🪙</span>
<div>
<div className="font-medium text-gray-900 dark:text-white">Add Tokens</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Grant tokens to a user</div>
</div>
</div>
</button>
<button className="w-full text-left p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div className="flex items-center space-x-3">
<span className="text-lg">📊</span>
<div>
<div className="font-medium text-gray-900 dark:text-white">View Reports</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Generate system reports</div>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
interface User {
first_name: string;
last_name: string;
email: string;
role: string;
}
interface AdminHeaderProps {
user?: User;
onLogout?: () => void;
}
export default function AdminHeader({ user, onLogout }: AdminHeaderProps) {
const [isProfileOpen, setIsProfileOpen] = useState(false);
return (
<header className="bg-white dark:bg-gray-900 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div className="px-6 py-4">
<div className="flex items-center justify-between">
{/* Page Title */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Admin Dashboard
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage users, jobs, and system settings
</p>
</div>
{/* User Menu */}
<div className="flex items-center space-x-4">
{/* Notifications */}
<button className="relative p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5v-5zM4 19h6v-6H4v6zM4 5h6V1H4v4zM15 7h5l-5-5v5z" />
</svg>
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full"></span>
</button>
{/* Profile Dropdown */}
<div className="relative">
<button
onClick={() => setIsProfileOpen(!isProfileOpen)}
className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<div className="w-8 h-8 bg-red-600 rounded-full flex items-center justify-center">
<span className="text-white font-medium text-sm">
{user?.first_name?.[0]}{user?.last_name?.[0]}
</span>
</div>
<div className="text-left">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{user?.first_name} {user?.last_name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{user?.role}
</div>
</div>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown Menu */}
{isProfileOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{user?.first_name} {user?.last_name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{user?.email}
</div>
</div>
<button className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
Profile Settings
</button>
<button className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
System Settings
</button>
<div className="border-t border-gray-200 dark:border-gray-700"></div>
<button
onClick={onLogout}
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
Sign Out
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</header>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { ReactNode } from "react";
import AdminSidebar from "./AdminSidebar";
import AdminHeader from "./AdminHeader";
interface User {
first_name: string;
last_name: string;
email: string;
role: string;
}
interface AdminLayoutProps {
children: ReactNode;
user?: User;
activeTab?: string;
onTabChange?: (tab: string) => void;
onLogout?: () => void;
}
export default function AdminLayout({
children,
user,
activeTab = "dashboard",
onTabChange,
onLogout
}: AdminLayoutProps) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex">
<AdminSidebar
activeTab={activeTab}
onTabChange={onTabChange}
/>
<div className="flex-1 flex flex-col">
<AdminHeader
user={user}
onLogout={onLogout}
/>
<main className="flex-1 p-6 bg-gray-50 dark:bg-gray-900">
{children}
</main>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
interface AdminSidebarProps {
activeTab?: string;
onTabChange?: (tab: string) => void;
}
export default function AdminSidebar({ activeTab = "dashboard", onTabChange }: AdminSidebarProps) {
const menuItems = [
{
id: "dashboard",
label: "Dashboard",
icon: "📊",
description: "Overview and analytics"
},
{
id: "users",
label: "User Management",
icon: "👥",
description: "Manage users and permissions"
},
{
id: "jobs",
label: "Job Management",
icon: "📢",
description: "View and manage all jobs"
},
{
id: "tokens",
label: "Token Management",
icon: "🪙",
description: "Manage interview tokens"
},
{
id: "stats",
label: "System Statistics",
icon: "📈",
description: "Detailed system metrics"
},
{
id: "devtools",
label: "Developer Tools",
icon: "🛠️",
description: "Swagger, Portainer, Docs"
}
];
return (
<div className="w-64 bg-white dark:bg-gray-900 shadow-sm border-r border-gray-200 dark:border-gray-700 flex flex-col">
{/* Logo */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center">
<div className="w-8 h-8 bg-red-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">A</span>
</div>
<div className="ml-3">
<span className="text-xl font-bold text-gray-900 dark:text-white">Admin Panel</span>
<p className="text-xs text-gray-500 dark:text-gray-400">Candivista</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-2">
{menuItems.map((item) => (
<button
key={item.id}
onClick={() => onTabChange?.(item.id)}
className={`w-full flex items-start px-3 py-3 text-sm font-medium rounded-lg transition-colors group ${
activeTab === item.id
? "text-white bg-red-600 dark:bg-red-700"
: "text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
>
<span className="text-lg mr-3 mt-0.5">{item.icon}</span>
<div className="text-left">
<div className="font-medium">{item.label}</div>
<div className={`text-xs mt-0.5 ${
activeTab === item.id
? "text-red-100"
: "text-gray-500 dark:text-gray-400"
}`}>
{item.description}
</div>
</div>
</button>
))}
</nav>
{/* Admin Badge */}
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center px-3 py-2 bg-red-50 dark:bg-red-900/20 rounded-lg">
<div className="w-2 h-2 bg-red-500 rounded-full mr-2"></div>
<span className="text-xs font-medium text-red-700 dark:text-red-300">
Admin Access
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
interface AnimatedCounterProps {
end: number;
duration?: number;
prefix?: string;
suffix?: string;
className?: string;
}
export default function AnimatedCounter({
end,
duration = 2000,
prefix = "",
suffix = "",
className = ""
}: AnimatedCounterProps) {
const [count, setCount] = useState(0);
useEffect(() => {
let startTime: number;
let animationFrame: number;
const animate = (currentTime: number) => {
if (!startTime) startTime = currentTime;
const progress = Math.min((currentTime - startTime) / duration, 1);
// Easing function for smooth animation
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
const currentCount = Math.floor(easeOutCubic * end);
setCount(currentCount);
if (progress < 1) {
animationFrame = requestAnimationFrame(animate);
}
};
animationFrame = requestAnimationFrame(animate);
return () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
};
}, [end, duration]);
return (
<span className={className}>
{prefix}{count.toLocaleString()}{suffix}
</span>
);
}
+403
View File
@@ -0,0 +1,403 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import { Job, Message } from '../types';
interface ChatScreenProps {
job: Job;
candidateName: string;
linkId: string;
isTestMode?: boolean;
mandatoryAnswers?: string[];
onComplete: () => void;
}
export default function ChatScreen({ job, candidateName, linkId, isTestMode = false, mandatoryAnswers = [], onComplete }: ChatScreenProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isInitializing, setIsInitializing] = useState(true);
const [error, setError] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [typingMessage, setTypingMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
// Build conversation history from mandatory answers for test mode
const buildConversationHistory = () => {
if (!isTestMode || !mandatoryAnswers.length) return [];
const history: Message[] = [];
const mandatoryQuestions = job.interview_questions || [];
for (let i = 0; i < mandatoryQuestions.length; i++) {
if (mandatoryAnswers[i]) {
history.push({
id: `q-${i + 1}`,
sender: 'ai',
content: `Question ${i + 1}: ${mandatoryQuestions[i]}`,
timestamp: new Date()
});
history.push({
id: `a-${i + 1}`,
sender: 'user',
content: mandatoryAnswers[i],
timestamp: new Date()
});
}
}
return history;
};
useEffect(() => {
scrollToBottom();
}, [messages, typingMessage]);
// Typing effect function
const simulateTyping = (text: string, onComplete: (finalText: string) => void) => {
setIsTyping(true);
setTypingMessage('');
let currentIndex = 0;
const typingSpeed = 20 + Math.random() * 30; // Random speed between 20-50ms per character
const typeNextCharacter = () => {
if (currentIndex < text.length) {
setTypingMessage(text.slice(0, currentIndex + 1));
currentIndex++;
setTimeout(typeNextCharacter, typingSpeed);
} else {
setIsTyping(false);
onComplete(text);
}
};
// Small delay before starting to type
setTimeout(typeNextCharacter, 500);
};
useEffect(() => {
initializeChat();
}, []);
const initializeChat = async () => {
try {
setIsInitializing(true);
// Send initial data to AI endpoint
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/rest/ai/start-interview${isTestMode ? '?test=true' : ''}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
candidateName,
job: job,
linkId,
test: isTestMode
})
});
if (response.ok) {
const data = await response.json();
// Use typing effect for AI's initial message
const aiMessage = data.message || "Hello! I'm your evaluation agent. Let's begin the interview for the " + job.title + " position. Please tell me about yourself and your interest in this role.";
simulateTyping(aiMessage, (finalText) => {
setMessages([{
id: '1',
content: finalText,
sender: 'ai',
timestamp: new Date()
}]);
});
} else {
throw new Error('Failed to initialize chat');
}
} catch (error) {
console.error('Error initializing chat:', error);
setError('Failed to start the interview. Please try again.');
// Add fallback message with typing effect
const fallbackMessage = "Hello! I'm your evaluation agent. Let's begin the interview for the " + job.title + " position. Please tell me about yourself and your interest in this role.";
simulateTyping(fallbackMessage, (finalText) => {
setMessages([{
id: '1',
content: finalText,
sender: 'ai',
timestamp: new Date()
}]);
});
} finally {
setIsInitializing(false);
}
};
const sendMessage = async () => {
if (!inputMessage.trim() || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
content: inputMessage.trim(),
sender: 'user',
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
setInputMessage('');
setIsLoading(true);
try {
// Build conversation history for test mode
const conversationHistory = isTestMode ?
[...buildConversationHistory(), ...messages.filter(msg => msg.content && msg.content !== 'undefined')] :
messages;
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/rest/ai/chat${isTestMode ? '?test=true' : ''}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: userMessage.content,
candidateName,
job: job,
linkId,
conversationHistory: conversationHistory.map(msg => ({
sender: msg.sender === 'user' ? 'candidate' : msg.sender,
message: msg.content,
timestamp: msg.timestamp
})),
test: isTestMode
})
});
if (response.ok) {
const data = await response.json();
// Use typing effect for AI response
const aiResponseText = data.message || "Thank you for your response. Let me ask you another question...";
simulateTyping(aiResponseText, (finalText) => {
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
content: finalText,
sender: 'ai',
timestamp: new Date()
};
setMessages(prev => [...prev, aiMessage]);
// Check if interview is complete
if (data.isComplete) {
setTimeout(() => {
onComplete();
}, 2000);
}
});
} else {
throw new Error('Failed to send message');
}
} catch (error) {
console.error('Error sending message:', error);
setError('Failed to send message. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
if (isInitializing) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Initializing interview...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Header */}
<div className="bg-white shadow-sm border-b px-4 py-4">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900">
Interview: {job.title}
</h1>
<p className="text-sm text-gray-600">
Candidate: {candidateName} {job.location || "Remote"}
</p>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span className="text-sm text-gray-500">AI Agent Online</span>
</div>
</div>
</div>
</div>
{/* Error Banner */}
{error && (
<div className="bg-red-50 border-b border-red-200 px-4 py-3">
<div className="max-w-4xl mx-auto">
<div className="flex items-center space-x-2">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-red-800">{error}</span>
<button
onClick={() => setError('')}
className="ml-auto text-red-600 hover:text-red-800"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-6">
<div className="max-w-4xl mx-auto space-y-6">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.sender === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-3xl px-6 py-4 rounded-2xl ${
message.sender === 'user'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-900 border border-gray-200'
}`}
>
<div className="flex items-start space-x-3">
{message.sender === 'ai' && (
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
)}
<div className="flex-1">
<p className="whitespace-pre-wrap">{message.content}</p>
<p className={`text-xs mt-2 ${
message.sender === 'user' ? 'text-blue-100' : 'text-gray-500'
}`}>
{formatTime(message.timestamp)}
</p>
</div>
{message.sender === 'user' && (
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
)}
</div>
</div>
</div>
))}
{isLoading && !isTyping && (
<div className="flex justify-start">
<div className="bg-white text-gray-900 border border-gray-200 rounded-2xl px-6 py-4">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
</div>
)}
{isTyping && (
<div className="flex justify-start">
<div className="bg-white text-gray-900 border border-gray-200 rounded-2xl px-6 py-4">
<div className="flex items-start space-x-3">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div className="flex-1">
<p className="whitespace-pre-wrap">{typingMessage}</p>
<div className="flex items-center mt-2">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
</div>
</div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input */}
<div className="bg-white border-t px-4 py-4">
<div className="max-w-4xl mx-auto">
<div className="flex space-x-4">
<div className="flex-1">
<textarea
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type your response here..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none text-gray-900 placeholder-gray-500 bg-white"
rows={3}
disabled={isLoading || isTyping}
/>
</div>
<button
onClick={sendMessage}
disabled={!inputMessage.trim() || isLoading || isTyping}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
)}
</button>
</div>
<p className="text-xs text-gray-500 mt-2">
Press Enter to send, Shift+Enter for new line
</p>
</div>
</div>
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useState } from 'react';
interface Job {
id: string;
title: string;
description: string;
requirements: string;
skills_required?: string[];
location?: string;
employment_type: string;
experience_level: string;
salary_min?: number;
salary_max?: number;
currency: string;
status: string;
icon?: string;
created_at: string;
updated_at: string;
}
interface ConsentScreenProps {
job: Job;
onConsent: (consent: boolean) => void;
}
export default function ConsentScreen({ job, onConsent }: ConsentScreenProps) {
const [isLoading, setIsLoading] = useState(false);
const handleConsent = async (consent: boolean) => {
setIsLoading(true);
// Small delay to show loading state
setTimeout(() => {
onConsent(consent);
}, 500);
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="max-w-2xl w-full">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 px-8 py-6 text-white">
<div className="text-center">
<div className="w-16 h-16 bg-white bg-opacity-20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h1 className="text-3xl font-bold mb-2">Welcome to Candivista</h1>
<p className="text-blue-100 text-lg">Initial Evaluation Platform</p>
</div>
</div>
{/* Content */}
<div className="px-8 py-8">
<div className="text-center mb-8">
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
Interview for {job.title}
</h2>
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<div className="flex items-center justify-center space-x-4 text-sm text-gray-600">
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{job.location || "Remote"}
</span>
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2-2v2m8 0V6a2 2 0 012 2v6a2 2 0 01-2 2H8a2 2 0 01-2-2V8a2 2 0 012-2V6" />
</svg>
{job.employment_type.replace('_', ' ')}
</span>
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{job.experience_level.replace('_', ' ')}
</span>
</div>
</div>
</div>
<div className="space-y-6">
<div className="text-center">
<p className="text-lg text-gray-700 leading-relaxed">
The following interview for the role of <strong>{job.title}</strong> will be conducted by our evaluation agent, which will ask you various questions and engage in evaluation discussion.
</p>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<div className="flex items-start space-x-3">
<svg className="w-6 h-6 text-yellow-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div>
<h3 className="text-lg font-semibold text-yellow-800 mb-2">Important Information</h3>
<p className="text-yellow-700 mb-3">
The information you share and the interview process is recorded for further evaluation. Please click on "I Agree" if you wish to proceed, or "I Disagree" if you wish to leave the page and stop the evaluation.
</p>
<p className="text-yellow-700">
<strong>Privacy Note:</strong> None of the information you share is shared with any third parties, organizations or any other entity except the job poster.
</p>
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<div className="flex items-start space-x-3">
<svg className="w-6 h-6 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h3 className="text-lg font-semibold text-blue-800 mb-2">What to Expect</h3>
<ul className="text-blue-700 space-y-1">
<li> Interactive conversation with our AI evaluation agent</li>
<li> Questions tailored to the {job.title} position</li>
<li> Assessment of your skills and experience</li>
<li> Professional and respectful evaluation process</li>
</ul>
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4 mt-8">
<button
onClick={() => handleConsent(false)}
disabled={isLoading}
className="flex-1 px-8 py-4 border-2 border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center justify-center space-x-2">
<span className="text-2xl">😢</span>
<span className="font-semibold">I Disagree</span>
</div>
<p className="text-sm text-gray-500 mt-1">Leave and stop evaluation</p>
</button>
<button
onClick={() => handleConsent(true)}
disabled={isLoading}
className="flex-1 px-8 py-4 bg-gradient-to-r from-green-600 to-emerald-600 text-white rounded-lg hover:from-green-700 hover:to-emerald-700 focus:outline-none focus:ring-2 focus:ring-green-500 transition-all transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
<div className="flex items-center justify-center space-x-2">
{isLoading ? (
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
) : (
<span className="text-2xl"></span>
)}
<span className="font-semibold">
{isLoading ? 'Processing...' : 'I Agree'}
</span>
</div>
<p className="text-sm text-green-100 mt-1">Proceed with evaluation</p>
</button>
</div>
</div>
</div>
</div>
</div>
);
}
+788
View File
@@ -0,0 +1,788 @@
"use client";
import { useState } from 'react';
import { z } from 'zod';
import axios from 'axios';
// Form validation schema
const createJobSchema = z.object({
title: z.string().min(1, 'Job title is required').max(255, 'Title too long'),
description: z.string().min(1, 'Job description is required'),
requirements: z.string().min(1, 'Job requirements are required'),
skills_required: z.array(z.string()).min(1, 'At least one skill is required'),
location: z.string().optional(),
employment_type: z.enum(['full_time', 'part_time', 'contract', 'internship']).default('full_time'),
experience_level: z.enum(['entry', 'mid', 'senior', 'lead', 'executive']).default('mid'),
salary_min: z.number().min(0).optional(),
salary_max: z.number().min(0).optional(),
currency: z.string().length(3).default('USD'),
application_deadline: z.string().optional(),
icon: z.string().optional(),
});
type CreateJobFormData = z.infer<typeof createJobSchema>;
interface CreateJobModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (jobData: CreateJobFormData) => void;
}
export default function CreateJobModal({ isOpen, onClose, onSubmit }: CreateJobModalProps) {
const [formData, setFormData] = useState<CreateJobFormData>({
title: '',
description: '',
requirements: '',
skills_required: [],
location: '',
employment_type: 'full_time',
experience_level: 'mid',
salary_min: undefined,
salary_max: undefined,
currency: 'USD',
application_deadline: '',
icon: 'briefcase',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [skillInput, setSkillInput] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [currentStep, setCurrentStep] = useState(1);
const [shakeAnimation, setShakeAnimation] = useState(false);
const totalSteps = 4;
// Available job icons
const jobIcons = [
{ id: 'briefcase', name: 'Briefcase', emoji: '💼' },
{ id: 'code', name: 'Code', emoji: '💻' },
{ id: 'chart', name: 'Analytics', emoji: '📊' },
{ id: 'design', name: 'Design', emoji: '🎨' },
{ id: 'marketing', name: 'Marketing', emoji: '📈' },
{ id: 'sales', name: 'Sales', emoji: '💼' },
{ id: 'support', name: 'Support', emoji: '🎧' },
{ id: 'engineering', name: 'Engineering', emoji: '⚙️' },
{ id: 'data', name: 'Data', emoji: '📊' },
{ id: 'security', name: 'Security', emoji: '🔒' },
{ id: 'mobile', name: 'Mobile', emoji: '📱' },
{ id: 'cloud', name: 'Cloud', emoji: '☁️' },
];
const handleInputChange = (field: keyof CreateJobFormData, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
// Clear shake animation when user starts typing
if (shakeAnimation) {
setShakeAnimation(false);
}
};
const handleAddSkill = () => {
if (skillInput.trim() && !formData.skills_required.includes(skillInput.trim())) {
setFormData(prev => ({
...prev,
skills_required: [...prev.skills_required, skillInput.trim()]
}));
setSkillInput('');
}
};
const handleRemoveSkill = (skillToRemove: string) => {
setFormData(prev => ({
...prev,
skills_required: prev.skills_required.filter(skill => skill !== skillToRemove)
}));
};
const validateCurrentStep = () => {
const newErrors: Record<string, string> = {};
if (currentStep === 1) {
// Validate Step 1: Basic Information
if (!formData.title.trim()) {
newErrors.title = 'Job title is required';
}
// Other fields in step 1 are optional
} else if (currentStep === 2) {
// Validate Step 2: Job Details
if (formData.skills_required.length === 0) {
newErrors.skills_required = 'At least one skill is required';
}
if (!formData.description.trim()) {
newErrors.description = 'Job description is required';
}
if (!formData.requirements.trim()) {
newErrors.requirements = 'Job requirements are required';
}
} else if (currentStep === 3) {
// Validate Step 3: Icon Selection
if (!formData.icon) {
newErrors.icon = 'Please select an icon for this job';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const nextStep = () => {
if (validateCurrentStep() && currentStep < totalSteps) {
setCurrentStep(currentStep + 1);
} else {
// Trigger shake animation when validation fails
setShakeAnimation(true);
setTimeout(() => setShakeAnimation(false), 500);
}
};
const prevStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log('Form submitted, current step:', currentStep, 'total steps:', totalSteps);
// Only create job if we're on the final step (review)
if (currentStep === totalSteps) {
console.log('Creating job on final step');
await createJob();
} else {
console.log('Not on final step, just validating');
// Just validate and move to next step
if (validateCurrentStep()) {
nextStep();
}
}
};
const createJob = async () => {
setIsSubmitting(true);
try {
console.log('Creating job with form data:', formData);
const validatedData = createJobSchema.parse(formData);
console.log('Validated data:', validatedData);
// Call the API to create the job
const token = localStorage.getItem("token");
console.log('Sending request to backend...');
const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/rest/jobs`, validatedData, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
console.log('Job created successfully:', response.data);
// Call the parent onSubmit callback
onSubmit(validatedData);
// Reset form
setFormData({
title: '',
description: '',
requirements: '',
skills_required: [],
location: '',
employment_type: 'full_time',
experience_level: 'mid',
salary_min: undefined,
salary_max: undefined,
currency: 'USD',
application_deadline: '',
});
setErrors({});
setCurrentStep(1);
onClose();
} catch (error) {
if (error instanceof z.ZodError) {
const fieldErrors: Record<string, string> = {};
error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message;
}
});
setErrors(fieldErrors);
} else {
console.error('Error creating job:', error);
// Handle API errors
if ((error as any).response) {
// Server responded with error status
console.error('API Error Response:', (error as any).response.data);
console.error('API Error Status:', (error as any).response.status);
console.error('API Error Headers:', (error as any).response.headers);
// Show user-friendly error message
alert(`Failed to create job: ${(error as any).response.data?.message || (error as any).response.statusText || 'Unknown error'}`);
} else if ((error as any).request) {
// Request was made but no response received
console.error('API Error Request:', (error as any).request);
alert('Failed to create job: No response from server. Please check if the backend is running.');
} else {
// Something else happened
console.error('API Error:', (error as any).message);
alert(`Failed to create job: ${(error as any).message}`);
}
}
} finally {
setIsSubmitting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[95vh] overflow-hidden">
{/* Header with Progress */}
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 px-8 py-6 text-white">
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-2xl font-bold">Create New Job</h2>
<p className="text-blue-100 mt-1">Step {currentStep} of {totalSteps}</p>
</div>
<button
onClick={onClose}
className="text-blue-200 hover:text-white transition-colors p-2 rounded-lg hover:bg-blue-700"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Progress Bar */}
<div className="w-full bg-blue-500 bg-opacity-30 rounded-full h-2">
<div
className="bg-white h-2 rounded-full transition-all duration-300 ease-out"
style={{ width: `${(currentStep / totalSteps) * 100}%` }}
></div>
</div>
{/* Step Indicators */}
<div className="flex justify-between mt-4">
{[1, 2, 3, 4].map((step) => {
const hasErrors = (step === 1 && errors.title) ||
(step === 2 && (errors.skills_required || errors.description || errors.requirements)) ||
(step === 3 && errors.icon);
return (
//step 3 shall be called "Icon" and step 4 shall be called "Review"
<div key={step} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-all duration-200 ${
step <= currentStep
? hasErrors
? 'bg-red-500 text-white animate-pulse'
: 'bg-white text-blue-600'
: 'bg-blue-500 bg-opacity-30 text-blue-200'
}`}>
{hasErrors ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
step
)}
</div>
<span className={`ml-2 text-sm transition-colors ${
hasErrors ? 'text-red-200' : 'text-blue-100'
}`}>
{step === 1 ? 'Basic Info' : step === 2 ? 'Details' : step === 3 ? 'Icon' : 'Review'}
</span>
</div>
);
})}
</div>
</div>
{/* Form Content */}
<div className={`p-8 overflow-y-auto max-h-[calc(95vh-200px)] transition-transform duration-500 ${
shakeAnimation ? 'animate-pulse' : ''
}`}>
{/* Validation Error Summary */}
{shakeAnimation && Object.keys(errors).length > 0 && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-start space-x-3">
<svg className="w-5 h-5 text-red-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="text-sm font-medium text-red-800 dark:text-red-200">Please complete the required fields:</h4>
<ul className="mt-2 text-sm text-red-700 dark:text-red-300 space-y-1">
{errors.title && <li> Job title is required</li>}
{errors.skills_required && <li> At least one skill is required</li>}
{errors.description && <li> Job description is required</li>}
{errors.requirements && <li> Job requirements are required</li>}
</ul>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} id="job-form">
{/* Step 1: Basic Information */}
{currentStep === 1 && (
<div className="space-y-6">
<div className="text-center mb-8">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Basic Information</h3>
<p className="text-gray-600 dark:text-gray-400">Let's start with the essential details about this position</p>
</div>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Job Title *
</label>
<input
type="text"
value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white transition-colors ${
errors.title ? 'border-red-500 bg-red-50 dark:bg-red-900/20 ring-2 ring-red-200 dark:ring-red-800' : 'border-gray-300'
}`}
placeholder="e.g. Senior Frontend Developer"
/>
{errors.title && <p className="text-red-500 text-sm mt-1">{errors.title}</p>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Employment Type
</label>
<select
value={formData.employment_type}
onChange={(e) => handleInputChange('employment_type', e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="full_time">Full Time</option>
<option value="part_time">Part Time</option>
<option value="contract">Contract</option>
<option value="internship">Internship</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Experience Level
</label>
<select
value={formData.experience_level}
onChange={(e) => handleInputChange('experience_level', e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="entry">Entry Level</option>
<option value="mid">Mid Level</option>
<option value="senior">Senior Level</option>
<option value="lead">Lead</option>
<option value="executive">Executive</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Location
</label>
<input
type="text"
value={formData.location}
onChange={(e) => handleInputChange('location', e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="e.g. New York, NY or Remote"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Application Deadline
</label>
<input
type="date"
value={formData.application_deadline}
onChange={(e) => handleInputChange('application_deadline', e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
</div>
</div>
)}
{/* Step 2: Job Details */}
{currentStep === 2 && (
<div className="space-y-6">
<div className="text-center mb-8">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Job Details</h3>
<p className="text-gray-600 dark:text-gray-400">Add comprehensive details about the role and compensation</p>
</div>
<div className="space-y-6">
{/* Salary Information */}
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-4">Compensation</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Min Salary
</label>
<input
type="number"
value={formData.salary_min || ''}
onChange={(e) => handleInputChange('salary_min', e.target.value ? Number(e.target.value) : undefined)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
placeholder="50000"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Max Salary
</label>
<input
type="number"
value={formData.salary_max || ''}
onChange={(e) => handleInputChange('salary_max', e.target.value ? Number(e.target.value) : undefined)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
placeholder="80000"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Currency
</label>
<select
value={formData.currency}
onChange={(e) => handleInputChange('currency', e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
<option value="CAD">CAD</option>
</select>
</div>
</div>
</div>
{/* Skills Required */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Required Skills *
</label>
<div className={`flex gap-2 mb-3 p-3 rounded-lg transition-colors ${
errors.skills_required ? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800' : ''
}`}>
<input
type="text"
value={skillInput}
onChange={(e) => setSkillInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddSkill())}
className={`flex-1 px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white transition-colors ${
errors.skills_required ? 'border-red-500 ring-2 ring-red-200 dark:ring-red-800' : 'border-gray-300'
}`}
placeholder="Add a skill and press Enter"
/>
<button
type="button"
onClick={handleAddSkill}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
>
Add
</button>
</div>
{errors.skills_required && (
<div className="flex items-center space-x-2 text-red-500 text-sm mb-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{errors.skills_required}</span>
</div>
)}
<div className="flex flex-wrap gap-2">
{formData.skills_required.map((skill, index) => (
<span
key={index}
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
>
{skill}
<button
type="button"
onClick={() => handleRemoveSkill(skill)}
className="ml-2 text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100"
>
×
</button>
</span>
))}
</div>
</div>
{/* Job Description */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Job Description *
</label>
<textarea
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
rows={5}
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white transition-colors ${
errors.description ? 'border-red-500 bg-red-50 dark:bg-red-900/20 ring-2 ring-red-200 dark:ring-red-800' : 'border-gray-300'
}`}
placeholder="Describe the role, responsibilities, and what makes this opportunity exciting..."
/>
{errors.description && (
<div className="flex items-center space-x-2 text-red-500 text-sm mt-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{errors.description}</span>
</div>
)}
</div>
{/* Job Requirements */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Job Requirements *
</label>
<textarea
value={formData.requirements}
onChange={(e) => handleInputChange('requirements', e.target.value)}
rows={5}
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white transition-colors ${
errors.requirements ? 'border-red-500 bg-red-50 dark:bg-red-900/20 ring-2 ring-red-200 dark:ring-red-800' : 'border-gray-300'
}`}
placeholder="List the specific requirements, qualifications, and experience needed..."
/>
{errors.requirements && (
<div className="flex items-center space-x-2 text-red-500 text-sm mt-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{errors.requirements}</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Step 3: Icon Selection */}
{currentStep === 3 && (
<div className="space-y-6">
<div className="text-center mb-8">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Choose an Icon</h3>
<p className="text-gray-600 dark:text-gray-400">Select an icon that best represents this job position</p>
</div>
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{jobIcons.map((icon) => (
<button
key={icon.id}
type="button"
onClick={() => handleInputChange('icon', icon.id)}
className={`p-4 rounded-lg border-2 transition-all duration-200 hover:scale-105 ${
formData.icon === icon.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 ring-2 ring-blue-200 dark:ring-blue-800'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
} ${errors.icon ? 'border-red-500 bg-red-50 dark:bg-red-900/20 ring-2 ring-red-200 dark:ring-red-800' : ''}`}
>
<div className="text-3xl mb-2">{icon.emoji}</div>
<div className="text-xs text-gray-600 dark:text-gray-400 text-center">{icon.name}</div>
</button>
))}
</div>
{errors.icon && (
<div className="text-red-500 text-sm text-center">{errors.icon}</div>
)}
</div>
)}
{/* Step 4: Review */}
{currentStep === 4 && (
<div className="space-y-6">
<div className="text-center mb-8">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Review & Create</h3>
<p className="text-gray-600 dark:text-gray-400">Review your job posting before publishing</p>
</div>
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg space-y-4">
{/* Job Preview Card */}
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-600">
<div className="flex items-start space-x-4">
<div className="text-4xl">
{jobIcons.find(icon => icon.id === formData.icon)?.emoji || '💼'}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{formData.title}</h3>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-1 line-clamp-2">
{formData.description?.substring(0, 100)}...
</p>
<div className="flex flex-wrap gap-2 mt-2">
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs rounded">
{formData.employment_type?.replace('_', ' ')}
</span>
<span className="px-2 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs rounded">
{formData.experience_level?.replace('_', ' ')}
</span>
{formData.location && (
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 text-xs rounded">
{formData.location}
</span>
)}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Job Title</h4>
<p className="text-gray-600 dark:text-gray-400">{formData.title || 'Not specified'}</p>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Employment Type</h4>
<p className="text-gray-600 dark:text-gray-400 capitalize">{formData.employment_type?.replace('_', ' ')}</p>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Experience Level</h4>
<p className="text-gray-600 dark:text-gray-400 capitalize">{formData.experience_level?.replace('_', ' ')}</p>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Location</h4>
<p className="text-gray-600 dark:text-gray-400">{formData.location || 'Not specified'}</p>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Salary Range</h4>
<p className="text-gray-600 dark:text-gray-400">
{formData.salary_min && formData.salary_max
? `${formData.currency} ${formData.salary_min.toLocaleString()} - ${formData.salary_max.toLocaleString()}`
: formData.salary_min
? `${formData.currency} ${formData.salary_min.toLocaleString()}+`
: 'Not specified'
}
</p>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Application Deadline</h4>
<p className="text-gray-600 dark:text-gray-400">
{formData.application_deadline
? new Date(formData.application_deadline).toLocaleDateString()
: 'No deadline set'
}
</p>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Required Skills</h4>
<div className="flex flex-wrap gap-2">
{formData.skills_required.length > 0 ? (
formData.skills_required.map((skill, index) => (
<span key={index} className="px-3 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-sm">
{skill}
</span>
))
) : (
<p className="text-gray-500 dark:text-gray-400">No skills specified</p>
)}
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Description</h4>
<p className="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">{formData.description || 'No description provided'}</p>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Requirements</h4>
<p className="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">{formData.requirements || 'No requirements specified'}</p>
</div>
</div>
</div>
)}
{/* Navigation Buttons */}
<div className="flex justify-between pt-8 border-t border-gray-200 dark:border-gray-700">
<div>
{currentStep > 1 && (
<button
type="button"
onClick={prevStep}
className="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors"
>
Previous
</button>
)}
</div>
<div className="flex space-x-4">
<button
type="button"
onClick={onClose}
className="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors"
>
Cancel
</button>
{currentStep < totalSteps ? (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (validateCurrentStep()) {
nextStep();
} else {
setShakeAnimation(true);
setTimeout(() => setShakeAnimation(false), 500);
}
}}
className={`px-6 py-3 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200 ${
shakeAnimation
? 'bg-red-600 hover:bg-red-700 ring-2 ring-red-300 animate-pulse'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{shakeAnimation ? 'Please fill required fields' : 'Next'}
</button>
) : (
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
>
{isSubmitting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Creating...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Create Job</span>
</>
)}
</button>
)}
</div>
</div>
</form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,39 @@
"use client";
export default function DeveloperTools() {
const swaggerUrl = process.env.NEXT_PUBLIC_API_URL ? `${process.env.NEXT_PUBLIC_API_URL}/doc` : "http://localhost:8083/doc";
const portainerUrl = "https://localhost:9443/#!/home"; // Adjust host if needed
const docsUrl = "/docs"; // Internal docs route with Swagger UI
return (
<div className="max-w-4xl mx-auto space-y-6">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Developer Tools</h1>
<p className="text-gray-600 dark:text-gray-300">Quick access to developer resources.</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<a
href={swaggerUrl}
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
>
<div className="text-xl">📜</div>
<div className="mt-2 font-medium text-gray-900 dark:text-white">Swagger API Docs</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Backend OpenAPI /doc</div>
</a>
<a
href={portainerUrl}
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
>
<div className="text-xl">🧰</div>
<div className="mt-2 font-medium text-gray-900 dark:text-white">Portainer</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Container management (:9443)</div>
</a>
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
interface FeatureCardProps {
icon: React.ReactNode;
title: string;
description: string;
gradient: string;
delay?: number;
}
export default function FeatureCard({
icon,
title,
description,
gradient,
delay = 0
}: FeatureCardProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className="group relative bg-white rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-500 transform hover:-translate-y-2 card-hover"
style={{ animationDelay: `${delay}ms` }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Background gradient on hover */}
<div
className={`absolute inset-0 rounded-2xl opacity-0 group-hover:opacity-10 transition-opacity duration-500 ${gradient}`}
/>
{/* Icon */}
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center mb-6 transition-all duration-500 group-hover:scale-110 ${gradient}`}>
<div className="text-white text-2xl">
{icon}
</div>
</div>
{/* Content */}
<div className="relative z-10">
<h3 className="text-2xl font-bold text-gray-900 mb-4 group-hover:text-blue-600 transition-colors duration-300">
{title}
</h3>
<p className="text-gray-600 text-lg leading-relaxed group-hover:text-gray-700 transition-colors duration-300">
{description}
</p>
</div>
{/* Hover effect line */}
<div
className={`absolute bottom-0 left-0 h-1 ${gradient} transition-all duration-500 ${
isHovered ? 'w-full' : 'w-0'
}`}
/>
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useState, useEffect } from "react";
import axios from "axios";
import ThemeToggle from "./ThemeToggle";
interface User {
first_name: string;
last_name: string;
email: string;
role?: string;
}
interface TokenSummary {
total_purchased: number;
total_used: number;
total_available: number;
utilization_percentage: number;
}
interface HeaderProps {
title: string;
user?: User;
onLogout?: () => void;
}
export default function Header({ title, user, onLogout }: HeaderProps) {
const [tokenSummary, setTokenSummary] = useState<TokenSummary | null>(null);
const [loadingTokens, setLoadingTokens] = useState(false);
useEffect(() => {
if (user && user.role === 'recruiter') {
fetchTokenSummary();
}
}, [user]);
// Listen for token updates from other components
useEffect(() => {
const handleTokenUpdate = () => {
console.log('Header received tokensUpdated event');
if (user && user.role === 'recruiter') {
console.log('Refreshing token summary...');
fetchTokenSummary();
}
};
window.addEventListener('tokensUpdated', handleTokenUpdate);
return () => window.removeEventListener('tokensUpdated', handleTokenUpdate);
}, [user]);
const fetchTokenSummary = async () => {
setLoadingTokens(true);
try {
const token = localStorage.getItem("token");
console.log('Fetching token summary...');
const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/user/token-summary`, {
headers: {
Authorization: `Bearer ${token}`
}
});
console.log('Token summary response:', response.data);
setTokenSummary(response.data);
} catch (error) {
console.error("Failed to fetch token summary:", error);
// Mock data for development
setTokenSummary({
total_purchased: 25,
total_used: 12,
total_available: 13,
utilization_percentage: 48
});
} finally {
setLoadingTokens(false);
}
};
return (
<header className="bg-white dark:bg-gray-900 shadow-sm border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{title}</h1>
<div className="flex items-center space-x-4">
<ThemeToggle />
{/* Token Display for Recruiters */}
{user?.role === 'recruiter' && (
<div className="flex items-center space-x-4">
{loadingTokens ? (
<div className="flex items-center space-x-2 text-gray-500 dark:text-gray-400">
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin"></div>
<span className="text-sm">Loading tokens...</span>
</div>
) : tokenSummary ? (
<div className="flex items-center space-x-4">
{/* Available Tokens */}
<div className="flex items-center space-x-2 bg-green-50 dark:bg-green-900/20 px-3 py-2 rounded-lg">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-sm font-medium text-green-700 dark:text-green-300">
{tokenSummary.total_available} tokens
</span>
</div>
{/* Token Usage Progress */}
<div className="flex items-center space-x-2">
<div className="w-16 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${tokenSummary.utilization_percentage}%` }}
></div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{tokenSummary.utilization_percentage}%
</span>
</div>
{/* Token Details Tooltip */}
<div className="group relative">
<button className="p-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-50">
<div className="text-sm">
<div className="font-medium text-gray-900 dark:text-white mb-2">Token Usage</div>
<div className="space-y-1 text-gray-600 dark:text-gray-400">
<div className="flex justify-between">
<span>Purchased:</span>
<span className="font-medium">{tokenSummary.total_purchased}</span>
</div>
<div className="flex justify-between">
<span>Used:</span>
<span className="font-medium">{tokenSummary.total_used}</span>
</div>
<div className="flex justify-between">
<span>Available:</span>
<span className="font-medium text-green-600 dark:text-green-400">{tokenSummary.total_available}</span>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="flex items-center space-x-2 text-gray-500 dark:text-gray-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm">No token data</span>
</div>
)}
</div>
)}
<button className="p-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5-5-5h5v-5a7.5 7.5 0 1 0-15 0v5h5l-5 5-5-5h5v-5a7.5 7.5 0 1 1 15 0v5z" />
</svg>
</button>
<button className="p-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-gray-300 dark:bg-gray-700 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{user?.first_name?.[0]}{user?.last_name?.[0]}
</span>
</div>
<button onClick={onLogout} className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</div>
</header>
);
}
File diff suppressed because it is too large Load Diff
+495
View File
@@ -0,0 +1,495 @@
"use client";
import { useState, useEffect } from "react";
import axios from "axios";
interface Job {
id: string;
user_id: string;
title: string;
description: string;
requirements: string;
skills_required: string[];
location: string;
employment_type: string;
experience_level: string;
salary_min?: number;
salary_max?: number;
currency: string;
status: string;
evaluation_criteria: any;
interview_questions: any;
application_deadline?: string;
icon?: string;
created_at: string;
updated_at: string;
user?: {
first_name: string;
last_name: string;
email: string;
company_name?: string;
};
}
interface JobStats {
total_applications: number;
interviews_completed: number;
tokens_used: number;
tokens_remaining: number;
}
export default function JobManagement() {
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [filterStatus, setFilterStatus] = useState("all");
const [filterUser, setFilterUser] = useState("all");
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const [isJobDetailsModalOpen, setIsJobDetailsModalOpen] = useState(false);
const [isAddTokensModalOpen, setIsAddTokensModalOpen] = useState(false);
const [isEditJobModalOpen, setIsEditJobModalOpen] = useState(false);
useEffect(() => {
fetchJobs();
}, []);
const fetchJobs = async () => {
try {
const token = localStorage.getItem("token");
const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/jobs`, {
headers: {
Authorization: `Bearer ${token}`
}
});
setJobs(response.data);
} catch (error) {
console.error("Failed to fetch jobs:", error);
// Mock data for development
setJobs([
{
id: "1",
user_id: "user1",
title: "Senior Frontend Developer",
description: "We're looking for a talented frontend developer to join our team and help build amazing user experiences.",
requirements: "5+ years experience with React, TypeScript, and modern frontend tools",
skills_required: ["React", "TypeScript", "Next.js", "Tailwind CSS"],
location: "San Francisco, CA",
employment_type: "full_time",
experience_level: "senior",
salary_min: 120000,
salary_max: 160000,
currency: "USD",
status: "active",
evaluation_criteria: {},
interview_questions: {},
created_at: "2024-01-15T10:00:00Z",
updated_at: "2024-01-15T10:00:00Z",
user: {
first_name: "John",
last_name: "Doe",
email: "john.doe@company.com",
company_name: "Tech Corp"
}
},
{
id: "2",
user_id: "user2",
title: "Full Stack Engineer",
description: "Join our engineering team to build scalable web applications using modern technologies.",
requirements: "3+ years experience with full-stack development",
skills_required: ["Node.js", "React", "PostgreSQL", "AWS"],
location: "Remote",
employment_type: "full_time",
experience_level: "mid",
salary_min: 90000,
salary_max: 130000,
currency: "USD",
status: "active",
evaluation_criteria: {},
interview_questions: {},
created_at: "2024-01-10T14:30:00Z",
updated_at: "2024-01-10T14:30:00Z",
user: {
first_name: "Jane",
last_name: "Smith",
email: "jane.smith@startup.com",
company_name: "Startup Inc"
}
}
]);
} finally {
setLoading(false);
}
};
const handleViewJob = (job: Job) => {
setSelectedJob(job);
setIsJobDetailsModalOpen(true);
};
const handleEditJob = (job: Job) => {
setSelectedJob(job);
setIsEditJobModalOpen(true);
};
const handleAddTokens = (job: Job) => {
setSelectedJob(job);
setIsAddTokensModalOpen(true);
};
const handleToggleJobStatus = async (job: Job) => {
try {
const token = localStorage.getItem("token");
const newStatus = job.status === "active" ? "paused" : "active";
await axios.patch(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/jobs/${job.id}/status`,
{ status: newStatus },
{
headers: {
Authorization: `Bearer ${token}`
}
}
);
await fetchJobs();
} catch (error) {
console.error("Failed to toggle job status:", error);
}
};
const filteredJobs = jobs.filter(job => {
const matchesSearch = job.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
job.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
job.user?.first_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
job.user?.last_name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = filterStatus === "all" || job.status === filterStatus;
const matchesUser = filterUser === "all" || job.user_id === filterUser;
return matchesSearch && matchesStatus && matchesUser;
});
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(amount);
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300';
case 'paused':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300';
case 'closed':
return 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300';
case 'draft':
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300';
}
};
const getExperienceLevelColor = (level: string) => {
switch (level) {
case 'entry':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300';
case 'mid':
return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300';
case 'senior':
return 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300';
case 'lead':
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-300';
case 'executive':
return 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300';
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading jobs...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">Job Management</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
View and manage all job postings across the platform
</p>
</div>
<div className="flex items-center space-x-2">
<button
onClick={fetchJobs}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
>
Refresh
</button>
</div>
</div>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Search Jobs
</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by title, description, or recruiter..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Filter by Status
</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="closed">Closed</option>
<option value="draft">Draft</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Filter by Recruiter
</label>
<select
value={filterUser}
onChange={(e) => setFilterUser(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Recruiters</option>
{Array.from(new Set(jobs.map(job => job.user_id))).map(userId => {
const user = jobs.find(job => job.user_id === userId)?.user;
return (
<option key={userId} value={userId}>
{user ? `${user.first_name} ${user.last_name}` : userId}
</option>
);
})}
</select>
</div>
</div>
</div>
{/* Jobs Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredJobs.map((job) => (
<div key={job.id} className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
{/* Job Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
{job.title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
by {job.user?.first_name} {job.user?.last_name}
</p>
{job.user?.company_name && (
<p className="text-xs text-gray-500 dark:text-gray-500">
{job.user.company_name}
</p>
)}
</div>
<div className="flex flex-col space-y-1">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(job.status)}`}>
{job.status}
</span>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getExperienceLevelColor(job.experience_level)}`}>
{job.experience_level}
</span>
</div>
</div>
{/* Job Details */}
<div className="space-y-3 mb-4">
<div className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{job.location}
</div>
<div className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
</svg>
{job.salary_min && job.salary_max
? `${formatCurrency(job.salary_min, job.currency)} - ${formatCurrency(job.salary_max, job.currency)}`
: 'Salary not specified'
}
</div>
<div className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Created {formatDate(job.created_at)}
</div>
</div>
{/* Skills */}
<div className="mb-4">
<div className="flex flex-wrap gap-1">
{job.skills_required.slice(0, 3).map((skill, index) => (
<span key={index} className="inline-flex px-2 py-1 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded">
{skill}
</span>
))}
{job.skills_required.length > 3 && (
<span className="inline-flex px-2 py-1 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded">
+{job.skills_required.length - 3} more
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex space-x-2">
<button
onClick={() => handleViewJob(job)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium"
>
View
</button>
<button
onClick={() => handleEditJob(job)}
className="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300 text-sm font-medium"
>
Edit
</button>
<button
onClick={() => handleAddTokens(job)}
className="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 text-sm font-medium"
>
Add Tokens
</button>
</div>
<button
onClick={() => handleToggleJobStatus(job)}
className={`text-sm font-medium ${
job.status === 'active'
? 'text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300'
: 'text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300'
}`}
>
{job.status === 'active' ? 'Pause' : 'Activate'}
</button>
</div>
</div>
))}
</div>
{filteredJobs.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2-2v2m8 0V6a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2V6" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
No jobs found
</h3>
<p className="text-gray-500 dark:text-gray-400">
Try adjusting your search criteria or filters.
</p>
</div>
)}
{/* Modals */}
{isJobDetailsModalOpen && selectedJob && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Job Details: {selectedJob.title}
</h3>
<div className="space-y-4">
<div>
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Description</h4>
<p className="text-gray-600 dark:text-gray-400">{selectedJob.description}</p>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Requirements</h4>
<p className="text-gray-600 dark:text-gray-400">{selectedJob.requirements}</p>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Skills Required</h4>
<div className="flex flex-wrap gap-2">
{selectedJob.skills_required.map((skill, index) => (
<span key={index} className="px-2 py-1 bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 rounded text-sm">
{skill}
</span>
))}
</div>
</div>
</div>
<div className="flex justify-end mt-6">
<button
onClick={() => setIsJobDetailsModalOpen(false)}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700"
>
Close
</button>
</div>
</div>
</div>
)}
{/* Add Tokens Modal */}
{isAddTokensModalOpen && selectedJob && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Add Tokens to Job
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Add interview tokens for: {selectedJob.title}
</p>
<div className="flex justify-end space-x-2">
<button
onClick={() => setIsAddTokensModalOpen(false)}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
Cancel
</button>
<button
onClick={() => setIsAddTokensModalOpen(false)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Add Tokens
</button>
</div>
</div>
</div>
)}
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
"use client";
import { useState } from "react";
import JobCard from "./JobCard";
import CreateJobModal from "./CreateJobModal";
interface Job {
id: string;
title: string;
description: string;
requirements: string;
skills_required?: string[];
location?: string;
employment_type: string;
experience_level: string;
salary_min?: number;
salary_max?: number;
currency: string;
status: string;
icon?: string;
created_at: string;
updated_at: string;
// Metrics
total_interviews?: number;
interviews_completed?: number;
available_interviews?: number;
running_days?: number;
applications?: number;
}
interface JobsListProps {
jobs: Job[];
onEditJob?: (job: Job) => void;
onDeleteJob?: (job: Job) => void;
onViewJob?: (job: Job) => void;
onRefreshJobs?: () => void;
}
export default function JobsList({ jobs, onEditJob, onDeleteJob, onViewJob, onRefreshJobs }: JobsListProps) {
const [activeTab, setActiveTab] = useState("active");
const [sortBy, setSortBy] = useState("created_date");
const [filterStatus, setFilterStatus] = useState("all");
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
// Debug logging
console.log("JobsList received jobs:", jobs);
console.log("Jobs count in JobsList:", jobs?.length || 0);
console.log("Job statuses:", jobs?.map(job => ({ id: job.id, status: job.status })));
console.log("Active tab:", activeTab);
const getJobMetrics = (job: any) => {
// Use values from backend; fallback to safe defaults
return {
interviews_completed: Number(job.interviews_completed || 0),
available_interviews: Number(job.available_interviews || 0),
total_interviews: Number(job.total_interviews || 0),
running_days: Number(job.running_days || 0),
expiry_date: job.application_deadline || undefined,
applications: Number(job.applications || 0),
links: Number(job.links || 0)
};
};
// Treat draft jobs as active so newly created jobs appear immediately
const filteredJobs = jobs.filter(job => {
if (activeTab === "active") return job.status === "active" || job.status === "draft";
if (activeTab === "archived") return job.status === "archived";
return true;
});
const activeJobsCount = jobs.filter(job => job.status === "active" || job.status === "draft").length;
const archivedJobsCount = jobs.filter(job => job.status === "archived").length;
const handleCreateJob = async (jobData: any) => {
console.log('Job created successfully:', jobData);
// Close the modal
setIsCreateModalOpen(false);
// Refresh the jobs list
if (onRefreshJobs) {
await onRefreshJobs();
}
};
const handleToggleExpanded = (jobId: string) => {
setExpandedJobId(expandedJobId === jobId ? null : jobId);
};
return (
<div className="space-y-6">
{/* Tabs and Filters */}
<div className="flex items-center justify-between">
<div className="flex space-x-1">
<button
onClick={() => setActiveTab("active")}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === "active"
? "bg-gray-900 dark:bg-gray-700 text-white"
: "text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
}`}
>
Active {activeJobsCount}
</button>
<button
onClick={() => setActiveTab("archived")}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === "archived"
? "bg-gray-900 dark:bg-gray-700 text-white"
: "text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
}`}
>
Archived {archivedJobsCount}
</button>
</div>
<div className="flex space-x-2">
<button
onClick={() => setIsCreateModalOpen(true)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center space-x-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span>Create Job</span>
</button>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="archived">Archived</option>
<option value="draft">Draft</option>
</select>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="created_date">Sort by: Created Date</option>
<option value="title">Sort by: Title</option>
<option value="applications">Sort by: Applications</option>
<option value="updated_date">Sort by: Updated Date</option>
</select>
</div>
</div>
{/* Jobs List */}
<div className="space-y-4">
{filteredJobs.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2-2v2m8 0V6a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2V6" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{activeTab === "active" ? "No active jobs" : "No archived jobs"}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{activeTab === "active"
? "Create your first job posting to get started."
: "Archived jobs will appear here."
}
</p>
{activeTab === "active" && (
<button
onClick={() => setIsCreateModalOpen(true)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
Create Job
</button>
)}
</div>
) : (
filteredJobs.map((job, index) => (
<JobCard
key={job.id}
job={{ ...job, ...getJobMetrics(job) }}
index={index}
onEdit={onEditJob}
onDelete={onDeleteJob}
onView={onViewJob}
isExpanded={expandedJobId === job.id}
onToggleExpanded={handleToggleExpanded}
/>
))
)}
</div>
{/* Create Job Modal */}
<CreateJobModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSubmit={handleCreateJob}
/>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { ReactNode } from "react";
import Sidebar from "./Sidebar";
import Header from "./Header";
interface User {
first_name: string;
last_name: string;
email: string;
}
interface LayoutProps {
children: ReactNode;
title: string;
user?: User;
activeSidebarItem?: string;
onSidebarItemClick?: (item: string) => void;
onLogout?: () => void;
}
export default function Layout({
children,
title,
user,
activeSidebarItem = "jobs",
onSidebarItemClick,
onLogout
}: LayoutProps) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex">
<Sidebar
activeItem={activeSidebarItem}
onItemClick={onSidebarItemClick}
/>
<div className="flex-1 flex flex-col">
<Header
title={title}
user={user}
onLogout={onLogout}
/>
<main className="flex-1 p-6 bg-gray-50 dark:bg-gray-900">
{children}
</main>
</div>
</div>
);
}
@@ -0,0 +1,185 @@
"use client";
import { useState, useEffect } from 'react';
interface Job {
id: string;
title: string;
description: string;
requirements: string;
skills_required?: string[];
location?: string;
employment_type: string;
experience_level: string;
salary_min?: number;
salary_max?: number;
currency: string;
status: string;
icon?: string;
created_at: string;
updated_at: string;
}
interface MandatoryQuestionsScreenProps {
job: Job;
candidateName: string;
linkId: string;
isTestMode?: boolean;
onComplete: (answers: string[]) => void;
}
export default function MandatoryQuestionsScreen({
job,
candidateName,
linkId,
isTestMode = false,
onComplete
}: MandatoryQuestionsScreenProps) {
const [questions, setQuestions] = useState<string[]>([]);
const [answers, setAnswers] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetchMandatoryQuestions();
}, []);
const fetchMandatoryQuestions = async () => {
try {
setIsLoading(true);
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/rest/ai/mandatory-questions/${linkId}`);
if (response.ok) {
const data = await response.json();
if (data.success && data.hasMandatoryQuestions) {
setQuestions(data.questions);
setAnswers(new Array(data.questions.length).fill(''));
} else {
// No mandatory questions, proceed directly
onComplete([]);
}
} else {
throw new Error('Failed to fetch mandatory questions');
}
} catch (error) {
console.error('Error fetching mandatory questions:', error);
setError('Failed to load questions. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleAnswerChange = (index: number, value: string) => {
const newAnswers = [...answers];
newAnswers[index] = value;
setAnswers(newAnswers);
};
const handleSubmit = async () => {
// Check if all questions are answered
const unansweredQuestions = answers.some(answer => answer.trim() === '');
if (unansweredQuestions) {
setError('Please answer all questions before continuing.');
return;
}
try {
setIsSubmitting(true);
setError('');
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/rest/ai/submit-mandatory-answers${isTestMode ? '?test=true' : ''}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
candidateName,
job,
linkId,
answers,
test: isTestMode
})
});
if (response.ok) {
onComplete(answers);
} else {
throw new Error('Failed to submit answers');
}
} catch (error) {
console.error('Error submitting answers:', error);
setError('Failed to submit answers. Please try again.');
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading questions...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto">
<div className="bg-white shadow rounded-lg p-6">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Welcome, {candidateName}!
</h1>
<p className="text-lg text-gray-600 mb-4">
Interview for <span className="font-semibold text-blue-600">{job.title}</span>
</p>
<p className="text-gray-600">
Please answer the following {questions.length} question{questions.length !== 1 ? 's' : ''} which are defined by the job poster:
</p>
</div>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-md p-4">
<p className="text-red-600">{error}</p>
</div>
)}
<div className="space-y-6">
{questions.map((question, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-6">
<label className="block text-sm font-medium text-gray-700 mb-3">
Question {index + 1}:
</label>
<p className="text-lg text-gray-900 mb-4 font-medium">
{question}
</p>
<textarea
value={answers[index] || ''}
onChange={(e) => handleAnswerChange(index, e.target.value)}
placeholder="Your answer here..."
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-gray-900 placeholder-gray-500 bg-white"
rows={4}
required
/>
</div>
))}
</div>
<div className="mt-8 flex justify-center">
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="bg-blue-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Submitting...' : 'Continue to Interview'}
</button>
</div>
</div>
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState } from 'react';
interface NameInputScreenProps {
onNameSubmit: (name: string) => void;
}
export default function NameInputScreen({ onNameSubmit }: NameInputScreenProps) {
const [fullName, setFullName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!fullName.trim()) {
setError('Please enter your full name');
return;
}
if (fullName.trim().split(' ').length < 2) {
setError('Please enter your first and last name');
return;
}
setIsLoading(true);
setError('');
// Small delay to show loading state
setTimeout(() => {
onNameSubmit(fullName.trim());
}, 500);
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 px-8 py-6 text-white">
<div className="text-center">
<div className="w-16 h-16 bg-white bg-opacity-20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<h1 className="text-2xl font-bold mb-2">Personal Information</h1>
<p className="text-blue-100">Please provide your full name to continue</p>
</div>
</div>
{/* Content */}
<div className="px-8 py-8">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 mb-2">
Full Name *
</label>
<input
type="text"
id="fullName"
value={fullName}
onChange={(e) => {
setFullName(e.target.value);
setError('');
}}
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors text-gray-900 placeholder-gray-500 ${
error ? 'border-red-500 bg-red-50' : 'border-gray-300 bg-white'
}`}
placeholder="Enter your first and last name"
disabled={isLoading}
/>
{error && (
<p className="text-red-500 text-sm mt-1 flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</p>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h3 className="text-sm font-semibold text-blue-800 mb-1">Privacy Notice</h3>
<p className="text-sm text-blue-700">
Your name is visible only to the job poster and will be used for evaluation purposes only.
</p>
</div>
</div>
</div>
<button
type="submit"
disabled={isLoading || !fullName.trim()}
className="w-full px-6 py-4 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
<div className="flex items-center justify-center space-x-2">
{isLoading ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Processing...</span>
</>
) : (
<>
<span>Continue</span>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</>
)}
</div>
</button>
</form>
</div>
</div>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
interface PricingCardProps {
name: string;
tokens: number;
price: string;
description: string;
popular?: boolean;
features: string[];
gradient: string;
}
export default function PricingCard({
name,
tokens,
price,
description,
popular = false,
features,
gradient
}: PricingCardProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className={`relative bg-white/10 backdrop-blur-sm rounded-2xl p-8 transition-all duration-500 ${
popular ? 'ring-2 ring-blue-500 scale-105' : ''
} hover:bg-white/20 hover:scale-105`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Popular badge */}
{popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<div className="bg-blue-500 text-white text-sm font-semibold px-4 py-2 rounded-full">
Most Popular
</div>
</div>
)}
{/* Gradient overlay on hover */}
<div
className={`absolute inset-0 rounded-2xl opacity-0 transition-opacity duration-500 ${gradient} ${
isHovered ? 'opacity-10' : ''
}`}
/>
<div className="relative z-10">
{/* Header */}
<div className="text-center mb-8">
<h3 className="text-2xl font-bold text-white mb-2">{name}</h3>
<div className="text-4xl font-bold text-white mb-2">{price}</div>
<div className="text-gray-300 mb-4">{tokens} Interview Tokens</div>
<p className="text-gray-400 text-sm">{description}</p>
</div>
{/* Features */}
<div className="space-y-3 mb-8">
{features.map((feature, index) => (
<div key={index} className="flex items-center space-x-3">
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-gray-300 text-sm">{feature}</span>
</div>
))}
</div>
{/* CTA Button */}
<button
className={`w-full py-3 rounded-lg font-semibold transition-all duration-300 transform ${
popular
? 'bg-white text-blue-600 hover:bg-gray-100 hover:scale-105'
: 'bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:from-blue-600 hover:to-purple-600 hover:scale-105'
} shadow-lg hover:shadow-xl`}
>
Get Started
</button>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
interface SidebarProps {
activeItem?: string;
onItemClick?: (item: string) => void;
}
export default function Sidebar({ activeItem = "jobs", onItemClick }: SidebarProps) {
const [searchQuery, setSearchQuery] = useState("");
const menuItems = [
{ id: "dashboard", label: "Dashboard", icon: "🏠" },
{ id: "analytics", label: "Analytics", icon: "📊" },
{ id: "jobs", label: "Jobs", icon: "📢" },
{ id: "interviews", label: "Interviews", icon: "📺" },
{ id: "candidates", label: "Candidates", icon: "👥" },
{ id: "company", label: "Company", icon: "🏢" },
{ id: "activity", label: "Activity Logs", icon: "🕒" },
{ id: "settings", label: "Settings", icon: "⚙️" }
];
return (
<div className="w-64 bg-white dark:bg-gray-900 shadow-sm border-r border-gray-200 dark:border-gray-700 flex flex-col">
{/* Logo */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">C</span>
</div>
<span className="ml-3 text-xl font-bold text-gray-900 dark:text-white">Candivista</span>
</div>
</div>
{/* Search */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm placeholder-gray-500 dark:placeholder-gray-400 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Search"
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<kbd className="inline-flex items-center px-2 py-1 border border-gray-200 dark:border-gray-600 rounded text-xs font-mono text-gray-500 dark:text-gray-400">K</kbd>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-2">
{menuItems.map((item) => (
<button
key={item.id}
onClick={() => onItemClick?.(item.id)}
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
activeItem === item.id
? "text-white bg-gray-900 dark:bg-gray-700"
: "text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
>
<span className="text-lg mr-3">{item.icon}</span>
{item.label}
</button>
))}
</nav>
</div>
);
}
+367
View File
@@ -0,0 +1,367 @@
"use client";
import { useState } from "react";
interface SystemStatistics {
total_users: number;
active_users: number;
total_jobs: number;
total_interviews: number;
total_tokens_purchased: number;
total_tokens_used: number;
total_revenue: number;
generated_at: string;
}
interface SystemStatsProps {
stats: SystemStatistics | null;
onRefresh: () => void;
}
export default function SystemStats({ stats, onRefresh }: SystemStatsProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [timeRange, setTimeRange] = useState("30d");
const handleRefresh = async () => {
setIsRefreshing(true);
await onRefresh();
setIsRefreshing(false);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
const formatNumber = (num: number) => {
return new Intl.NumberFormat('en-US').format(num);
};
const getTokenUtilization = () => {
if (!stats || stats.total_tokens_purchased === 0) return 0;
return Math.round((stats.total_tokens_used / stats.total_tokens_purchased) * 100);
};
const getActiveUserPercentage = () => {
if (!stats || stats.total_users === 0) return 0;
return Math.round((stats.active_users / stats.total_users) * 100);
};
const getAverageTokensPerUser = () => {
if (!stats || stats.total_users === 0) return 0;
return Math.round(stats.total_tokens_purchased / stats.total_users);
};
const getAverageInterviewsPerJob = () => {
if (!stats || stats.total_jobs === 0) return 0;
return Math.round(stats.total_interviews / stats.total_jobs);
};
const getRevenuePerUser = () => {
if (!stats || stats.total_users === 0) return 0;
return stats.total_revenue / stats.total_users;
};
const getRevenuePerToken = () => {
if (!stats || stats.total_tokens_purchased === 0) return 0;
return stats.total_revenue / stats.total_tokens_purchased;
};
const mockChartData = {
userGrowth: [
{ month: 'Jan', users: 45 },
{ month: 'Feb', users: 52 },
{ month: 'Mar', users: 48 },
{ month: 'Apr', users: 61 },
{ month: 'May', users: 55 },
{ month: 'Jun', users: 67 },
{ month: 'Jul', users: 73 },
{ month: 'Aug', users: 78 },
{ month: 'Sep', users: 82 },
{ month: 'Oct', users: 89 },
{ month: 'Nov', users: 95 },
{ month: 'Dec', users: 102 }
],
revenue: [
{ month: 'Jan', revenue: 1250 },
{ month: 'Feb', revenue: 1890 },
{ month: 'Mar', revenue: 2100 },
{ month: 'Apr', revenue: 2750 },
{ month: 'May', revenue: 3200 },
{ month: 'Jun', revenue: 4100 },
{ month: 'Jul', revenue: 4800 },
{ month: 'Aug', revenue: 5200 },
{ month: 'Sep', revenue: 6100 },
{ month: 'Oct', revenue: 6800 },
{ month: 'Nov', revenue: 7500 },
{ month: 'Dec', revenue: 8200 }
],
tokenUsage: [
{ month: 'Jan', purchased: 250, used: 180 },
{ month: 'Feb', purchased: 320, used: 240 },
{ month: 'Mar', purchased: 380, used: 290 },
{ month: 'Apr', purchased: 450, used: 350 },
{ month: 'May', purchased: 520, used: 410 },
{ month: 'Jun', purchased: 610, used: 480 },
{ month: 'Jul', purchased: 680, used: 520 },
{ month: 'Aug', purchased: 750, used: 580 },
{ month: 'Sep', purchased: 820, used: 650 },
{ month: 'Oct', purchased: 890, used: 720 },
{ month: 'Nov', purchased: 950, used: 780 },
{ month: 'Dec', purchased: 1020, used: 850 }
]
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">System Statistics</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Detailed analytics and performance metrics
</p>
</div>
<div className="flex items-center space-x-4">
<select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
<option value="1y">Last year</option>
</select>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
<svg className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{isRefreshing ? 'Refreshing...' : 'Refresh'}</span>
</button>
</div>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Users</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats ? formatNumber(stats.total_users) : '--'}
</p>
<p className="text-sm text-green-600 dark:text-green-400 mt-1">
{stats ? getActiveUserPercentage() : 0}% active
</p>
</div>
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
<span className="text-2xl">👥</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Revenue</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats ? formatCurrency(stats.total_revenue) : '$0'}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{stats ? formatCurrency(getRevenuePerUser()) : '$0'} per user
</p>
</div>
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
<span className="text-2xl">💰</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Token Utilization</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats ? getTokenUtilization() : 0}%
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{stats ? `${formatNumber(stats.total_tokens_used)} / ${formatNumber(stats.total_tokens_purchased)}` : '0 / 0'}
</p>
</div>
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900/20 rounded-lg flex items-center justify-center">
<span className="text-2xl">🪙</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Avg Tokens/User</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats ? getAverageTokensPerUser() : 0}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{stats ? formatCurrency(getRevenuePerToken()) : '$0'} per token
</p>
</div>
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
<span className="text-2xl">📊</span>
</div>
</div>
</div>
</div>
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* User Growth Chart */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">User Growth</h3>
<div className="h-64 flex items-end space-x-2">
{mockChartData.userGrowth.map((data, index) => (
<div key={index} className="flex-1 flex flex-col items-center">
<div
className="w-full bg-blue-500 rounded-t"
style={{ height: `${(data.users / 120) * 200}px` }}
></div>
<span className="text-xs text-gray-500 dark:text-gray-400 mt-2">{data.month}</span>
</div>
))}
</div>
</div>
{/* Revenue Chart */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Revenue Growth</h3>
<div className="h-64 flex items-end space-x-2">
{mockChartData.revenue.map((data, index) => (
<div key={index} className="flex-1 flex flex-col items-center">
<div
className="w-full bg-green-500 rounded-t"
style={{ height: `${(data.revenue / 9000) * 200}px` }}
></div>
<span className="text-xs text-gray-500 dark:text-gray-400 mt-2">{data.month}</span>
</div>
))}
</div>
</div>
</div>
{/* Detailed Statistics */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Platform Usage */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Platform Usage</h3>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Total Jobs Created</span>
<span className="font-semibold text-gray-900 dark:text-white">
{stats ? formatNumber(stats.total_jobs) : '--'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Total Interviews Completed</span>
<span className="font-semibold text-gray-900 dark:text-white">
{stats ? formatNumber(stats.total_interviews) : '--'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Average Interviews per Job</span>
<span className="font-semibold text-gray-900 dark:text-white">
{stats ? getAverageInterviewsPerJob() : '--'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Token Utilization Rate</span>
<span className="font-semibold text-gray-900 dark:text-white">
{stats ? getTokenUtilization() : 0}%
</span>
</div>
</div>
</div>
{/* Financial Metrics */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Financial Metrics</h3>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Total Revenue</span>
<span className="font-semibold text-gray-900 dark:text-white">
{stats ? formatCurrency(stats.total_revenue) : '$0'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Revenue per User</span>
<span className="font-semibold text-gray-900 dark:text-white">
{stats ? formatCurrency(getRevenuePerUser()) : '$0'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Revenue per Token</span>
<span className="font-semibold text-gray-900 dark:text-white">
{stats ? formatCurrency(getRevenuePerToken()) : '$0'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Average Token Price</span>
<span className="font-semibold text-gray-900 dark:text-white">
{stats && stats.total_tokens_purchased > 0
? formatCurrency(stats.total_revenue / stats.total_tokens_purchased)
: '$0'
}
</span>
</div>
</div>
</div>
</div>
{/* Token Usage Chart */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Token Usage Over Time</h3>
<div className="h-64 flex items-end space-x-2">
{mockChartData.tokenUsage.map((data, index) => (
<div key={index} className="flex-1 flex flex-col items-center">
<div className="w-full flex flex-col items-end space-y-1">
<div
className="w-full bg-blue-500 rounded-t"
style={{ height: `${(data.purchased / 1200) * 100}px` }}
title={`Purchased: ${data.purchased}`}
></div>
<div
className="w-full bg-green-500 rounded-t"
style={{ height: `${(data.used / 1200) * 100}px` }}
title={`Used: ${data.used}`}
></div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 mt-2">{data.month}</span>
</div>
))}
</div>
<div className="flex justify-center space-x-6 mt-4">
<div className="flex items-center">
<div className="w-3 h-3 bg-blue-500 rounded mr-2"></div>
<span className="text-sm text-gray-600 dark:text-gray-400">Purchased</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-green-500 rounded mr-2"></div>
<span className="text-sm text-gray-600 dark:text-gray-400">Used</span>
</div>
</div>
</div>
{/* Last Updated */}
{stats && (
<div className="text-center text-sm text-gray-500 dark:text-gray-400">
Last updated: {new Date(stats.generated_at).toLocaleString()}
</div>
)}
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
interface TechStackCardProps {
name: string;
icon: string;
description: string;
delay?: number;
}
export default function TechStackCard({
name,
icon,
description,
delay = 0
}: TechStackCardProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className="bg-white rounded-2xl p-6 shadow-lg hover:shadow-2xl transition-all duration-500 text-center group hover:scale-105 card-hover"
style={{ animationDelay: `${delay}ms` }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Icon with animation */}
<div
className={`text-4xl mb-4 transition-all duration-500 ${
isHovered ? 'scale-125 rotate-12' : 'scale-100 rotate-0'
}`}
>
{icon}
</div>
{/* Name */}
<h3 className="text-lg font-semibold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors duration-300">
{name}
</h3>
{/* Description */}
<p className="text-gray-600 text-sm group-hover:text-gray-700 transition-colors duration-300">
{description}
</p>
{/* Hover effect */}
<div
className={`absolute inset-0 rounded-2xl bg-gradient-to-r from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500`}
/>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<button className="p-2 rounded-lg bg-gray-100 text-gray-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</button>
);
}
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
aria-label="Toggle theme"
>
{theme === 'dark' ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
);
}
+678
View File
@@ -0,0 +1,678 @@
"use client";
import { useState, useEffect } from "react";
import axios from "axios";
interface TokenPackage {
id: string;
name: string;
description: string;
quantity: number;
price_per_token: number;
total_price: number;
discount_percentage: number;
is_popular: boolean;
is_active: boolean;
}
interface User {
id: string;
first_name: string;
last_name: string;
email: string;
company_name?: string;
}
interface UserTokenSummary {
user_id: string;
total_purchased: number;
total_used: number;
total_available: number;
utilization_percentage: number;
}
export default function TokenManagement() {
const [tokenPackages, setTokenPackages] = useState<TokenPackage[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [userTokenSummaries, setUserTokenSummaries] = useState<UserTokenSummary[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isAddTokensModalOpen, setIsAddTokensModalOpen] = useState(false);
const [isCreatePackageModalOpen, setIsCreatePackageModalOpen] = useState(false);
const [isEditPackageModalOpen, setIsEditPackageModalOpen] = useState(false);
const [selectedPackage, setSelectedPackage] = useState<TokenPackage | null>(null);
// Form states for adding tokens
const [tokenForm, setTokenForm] = useState({
user_id: "",
quantity: 1,
price_per_token: 5.00,
total_price: 5.00
});
// Form states for creating/editing packages
const [packageForm, setPackageForm] = useState({
name: "",
description: "",
quantity: 1,
price_per_token: 5.00,
total_price: 5.00,
discount_percentage: 0,
is_popular: false,
is_active: true
});
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const token = localStorage.getItem("token");
// Fetch token packages
const packagesResponse = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/token-packages`, {
headers: { Authorization: `Bearer ${token}` }
});
setTokenPackages(packagesResponse.data);
// Fetch users
const usersResponse = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/users`, {
headers: { Authorization: `Bearer ${token}` }
});
setUsers(usersResponse.data);
// Fetch user token summaries
const summariesResponse = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/user-token-summaries`, {
headers: { Authorization: `Bearer ${token}` }
});
setUserTokenSummaries(summariesResponse.data);
} catch (error) {
console.error("Failed to fetch data:", error);
// Mock data for development
setTokenPackages([
{
id: "1",
name: "Single Token",
description: "Perfect for testing the platform",
quantity: 1,
price_per_token: 5.00,
total_price: 5.00,
discount_percentage: 0,
is_popular: false,
is_active: true
},
{
id: "2",
name: "Professional Pack",
description: "Ideal for regular recruiters",
quantity: 20,
price_per_token: 4.00,
total_price: 80.00,
discount_percentage: 20,
is_popular: true,
is_active: true
},
{
id: "3",
name: "Enterprise Pack",
description: "Maximum value for large teams",
quantity: 100,
price_per_token: 3.00,
total_price: 300.00,
discount_percentage: 40,
is_popular: false,
is_active: true
}
]);
setUsers([
{
id: "user1",
first_name: "John",
last_name: "Doe",
email: "john.doe@company.com",
company_name: "Tech Corp"
},
{
id: "user2",
first_name: "Jane",
last_name: "Smith",
email: "jane.smith@startup.com",
company_name: "Startup Inc"
}
]);
setUserTokenSummaries([
{
user_id: "user1",
total_purchased: 25,
total_used: 12,
total_available: 13,
utilization_percentage: 48
},
{
user_id: "user2",
total_purchased: 50,
total_used: 30,
total_available: 20,
utilization_percentage: 60
}
]);
} finally {
setLoading(false);
}
};
const handleAddTokens = (user: User) => {
setSelectedUser(user);
setTokenForm({
user_id: user.id,
quantity: 1,
price_per_token: 5.00,
total_price: 5.00
});
setIsAddTokensModalOpen(true);
};
const handleCreatePackage = () => {
setPackageForm({
name: "",
description: "",
quantity: 1,
price_per_token: 5.00,
total_price: 5.00,
discount_percentage: 0,
is_popular: false,
is_active: true
});
setIsCreatePackageModalOpen(true);
};
const handleEditPackage = (pkg: TokenPackage) => {
setSelectedPackage(pkg);
setPackageForm({
name: pkg.name,
description: pkg.description,
quantity: pkg.quantity,
price_per_token: pkg.price_per_token,
total_price: pkg.total_price,
discount_percentage: pkg.discount_percentage,
is_popular: pkg.is_popular,
is_active: pkg.is_active
});
setIsEditPackageModalOpen(true);
};
const handleSubmitTokens = async () => {
try {
const token = localStorage.getItem("token");
await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/add-tokens`, tokenForm, {
headers: { Authorization: `Bearer ${token}` }
});
await fetchData();
setIsAddTokensModalOpen(false);
} catch (error) {
console.error("Failed to add tokens:", error);
}
};
const handleSubmitPackage = async () => {
try {
const token = localStorage.getItem("token");
if (isEditPackageModalOpen && selectedPackage) {
await axios.put(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/token-packages/${selectedPackage.id}`, packageForm, {
headers: { Authorization: `Bearer ${token}` }
});
} else {
await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/token-packages`, packageForm, {
headers: { Authorization: `Bearer ${token}` }
});
}
await fetchData();
setIsCreatePackageModalOpen(false);
setIsEditPackageModalOpen(false);
} catch (error) {
console.error("Failed to save package:", error);
}
};
const handleTogglePackageStatus = async (pkg: TokenPackage) => {
try {
const token = localStorage.getItem("token");
await axios.patch(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/token-packages/${pkg.id}/toggle-status`, {}, {
headers: { Authorization: `Bearer ${token}` }
});
await fetchData();
} catch (error) {
console.error("Failed to toggle package status:", error);
}
};
const updateTokenForm = (field: string, value: any) => {
setTokenForm(prev => {
const updated = { ...prev, [field]: value };
if (field === 'quantity' || field === 'price_per_token') {
updated.total_price = updated.quantity * updated.price_per_token;
}
return updated;
});
};
const updatePackageForm = (field: string, value: any) => {
setPackageForm(prev => {
const updated = { ...prev, [field]: value };
if (field === 'quantity' || field === 'price_per_token') {
updated.total_price = updated.quantity * updated.price_per_token;
}
return updated;
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
const getUserTokenSummary = (userId: string) => {
return userTokenSummaries.find(summary => summary.user_id === userId);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading token management...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">Token Management</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Manage interview tokens and token packages
</p>
</div>
<div className="flex space-x-2">
<button
onClick={handleCreatePackage}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span>Create Package</span>
</button>
</div>
</div>
{/* Token Packages Section */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Token Packages</h3>
<p className="text-gray-600 dark:text-gray-400 mt-1">Manage available token packages for purchase</p>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tokenPackages.map((pkg) => (
<div key={pkg.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">{pkg.name}</h4>
{pkg.is_popular && (
<span className="inline-flex px-2 py-1 text-xs font-semibold bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 rounded-full">
Popular
</span>
)}
</div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
pkg.is_active
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300'
}`}>
{pkg.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{pkg.description}</p>
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Quantity:</span>
<span className="font-medium text-gray-900 dark:text-white">{pkg.quantity} tokens</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Price per token:</span>
<span className="font-medium text-gray-900 dark:text-white">{formatCurrency(pkg.price_per_token)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Total price:</span>
<span className="font-medium text-gray-900 dark:text-white">{formatCurrency(pkg.total_price)}</span>
</div>
{pkg.discount_percentage > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Discount:</span>
<span className="font-medium text-green-600 dark:text-green-400">{pkg.discount_percentage}%</span>
</div>
)}
</div>
<div className="flex space-x-2">
<button
onClick={() => handleEditPackage(pkg)}
className="flex-1 px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Edit
</button>
<button
onClick={() => handleTogglePackageStatus(pkg)}
className={`flex-1 px-3 py-2 text-sm rounded transition-colors ${
pkg.is_active
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{pkg.is_active ? 'Deactivate' : 'Activate'}
</button>
</div>
</div>
))}
</div>
</div>
</div>
{/* User Token Management Section */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">User Token Management</h3>
<p className="text-gray-600 dark:text-gray-400 mt-1">View and manage tokens for individual users</p>
</div>
<div className="p-6">
<div className="mb-4">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search users..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Tokens Purchased
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Tokens Used
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Tokens Available
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Utilization
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{users
.filter(user =>
user.first_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.last_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.email.toLowerCase().includes(searchQuery.toLowerCase())
)
.map((user) => {
const summary = getUserTokenSummary(user.id);
return (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="w-10 h-10 bg-gray-300 dark:bg-gray-600 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{user.first_name[0]}{user.last_name[0]}
</span>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{user.first_name} {user.last_name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{user.email}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{summary?.total_purchased || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{summary?.total_used || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{summary?.total_available || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="w-16 bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${summary?.utilization_percentage || 0}%` }}
></div>
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">
{summary?.utilization_percentage || 0}%
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleAddTokens(user)}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
Add Tokens
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
{/* Add Tokens Modal */}
{isAddTokensModalOpen && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Add Tokens to {selectedUser.first_name} {selectedUser.last_name}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Quantity
</label>
<input
type="number"
value={tokenForm.quantity}
onChange={(e) => updateTokenForm('quantity', parseInt(e.target.value) || 1)}
min="1"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Price per Token
</label>
<input
type="number"
step="0.01"
value={tokenForm.price_per_token}
onChange={(e) => updateTokenForm('price_per_token', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="bg-gray-50 dark:bg-gray-700 p-3 rounded-lg">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Total Price:</span>
<span className="font-medium text-gray-900 dark:text-white">
{formatCurrency(tokenForm.total_price)}
</span>
</div>
</div>
</div>
<div className="flex justify-end space-x-2 mt-6">
<button
onClick={() => setIsAddTokensModalOpen(false)}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
Cancel
</button>
<button
onClick={handleSubmitTokens}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Add Tokens
</button>
</div>
</div>
</div>
)}
{/* Create/Edit Package Modal */}
{(isCreatePackageModalOpen || isEditPackageModalOpen) && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{isCreatePackageModalOpen ? 'Create Token Package' : 'Edit Token Package'}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Package Name
</label>
<input
type="text"
value={packageForm.name}
onChange={(e) => updatePackageForm('name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Description
</label>
<textarea
value={packageForm.description}
onChange={(e) => updatePackageForm('description', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Quantity
</label>
<input
type="number"
value={packageForm.quantity}
onChange={(e) => updatePackageForm('quantity', parseInt(e.target.value) || 1)}
min="1"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Price per Token
</label>
<input
type="number"
step="0.01"
value={packageForm.price_per_token}
onChange={(e) => updatePackageForm('price_per_token', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Discount Percentage
</label>
<input
type="number"
step="0.01"
value={packageForm.discount_percentage}
onChange={(e) => updatePackageForm('discount_percentage', parseFloat(e.target.value) || 0)}
min="0"
max="100"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="bg-gray-50 dark:bg-gray-700 p-3 rounded-lg">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Total Price:</span>
<span className="font-medium text-gray-900 dark:text-white">
{formatCurrency(packageForm.total_price)}
</span>
</div>
</div>
<div className="flex items-center space-x-4">
<label className="flex items-center">
<input
type="checkbox"
checked={packageForm.is_popular}
onChange={(e) => updatePackageForm('is_popular', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">Popular Package</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={packageForm.is_active}
onChange={(e) => updatePackageForm('is_active', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">Active</span>
</label>
</div>
</div>
<div className="flex justify-end space-x-2 mt-6">
<button
onClick={() => {
setIsCreatePackageModalOpen(false);
setIsEditPackageModalOpen(false);
}}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
Cancel
</button>
<button
onClick={handleSubmitPackage}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{isCreatePackageModalOpen ? 'Create Package' : 'Update Package'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import React, { useState } from 'react';
interface TokenModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (amount: number) => Promise<void>;
type: 'add' | 'remove';
currentTokens: number;
maxTokens?: number;
userTokens?: number;
}
export default function TokenModal({
isOpen,
onClose,
onConfirm,
type,
currentTokens,
maxTokens,
userTokens
}: TokenModalProps) {
const [amount, setAmount] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (amount <= 0) return;
setLoading(true);
setError(null);
try {
await onConfirm(amount);
onClose();
setAmount(1);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (!loading) {
setAmount(1);
setError(null);
onClose();
}
};
const maxAmount = type === 'add' ? userTokens : maxTokens;
const exceedsMax = typeof maxAmount === 'number' ? amount > maxAmount : false;
const isDisabled = !!(loading || amount <= 0 || exceedsMax);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-96 max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{type === 'add' ? 'Add Tokens' : 'Remove Tokens'}
</h3>
<button
onClick={handleClose}
disabled={loading}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-50"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Current tokens on link:</span>
<span className="font-semibold text-gray-900 dark:text-white">{currentTokens}</span>
</div>
{userTokens !== undefined && type === 'add' && (
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Your available tokens:</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">{userTokens}</span>
</div>
)}
</div>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{type === 'add' ? 'Tokens to add:' : 'Tokens to remove:'}
</label>
<input
type="number"
min="1"
max={maxAmount}
value={amount}
onChange={(e) => setAmount(parseInt(e.target.value) || 1)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={`Enter amount (max: ${maxAmount})`}
disabled={loading}
/>
{maxAmount && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Maximum: {maxAmount} tokens
</p>
)}
</div>
{error && (
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200 text-sm rounded">
{error}
</div>
)}
<div className="flex space-x-3">
<button
type="button"
onClick={handleClose}
disabled={loading}
className="flex-1 px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isDisabled}
className={`flex-1 px-4 py-2 text-white rounded-md transition-colors disabled:opacity-50 ${
type === 'add'
? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700'
}`}
>
{loading ? (
<div className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</div>
) : (
`${type === 'add' ? 'Add' : 'Remove'} Tokens`
)}
</button>
</div>
</form>
</div>
</div>
);
}
+599
View File
@@ -0,0 +1,599 @@
"use client";
import { useState, useEffect } from "react";
import axios from "axios";
interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: 'admin' | 'recruiter';
company_name?: string;
avatar_url?: string;
is_active: boolean;
last_login_at?: string;
email_verified_at?: string;
created_at: string;
updated_at: string;
}
interface UserStats {
jobs_created: number;
interviews_completed: number;
tokens_purchased: number;
tokens_used: number;
}
export default function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [filterRole, setFilterRole] = useState("all");
const [filterStatus, setFilterStatus] = useState("all");
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isAddUserModalOpen, setIsAddUserModalOpen] = useState(false);
const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false);
const [isAddTokensModalOpen, setIsAddTokensModalOpen] = useState(false);
const [addTokensForm, setAddTokensForm] = useState({ quantity: 1, price_per_token: 5.0 });
const [addUserForm, setAddUserForm] = useState({
first_name: "",
last_name: "",
email: "",
company_name: "",
role: "recruiter" as 'admin' | 'recruiter',
password: "",
confirm_password: ""
});
const [isCreatingUser, setIsCreatingUser] = useState(false);
const [createUserError, setCreateUserError] = useState<string | null>(null);
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const token = localStorage.getItem("token");
const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/users`, {
headers: {
Authorization: `Bearer ${token}`
}
});
setUsers(response.data);
} catch (error) {
console.error("Failed to fetch users:", error);
} finally {
setLoading(false);
}
};
const handleEditUser = (user: User) => {
setSelectedUser(user);
setIsEditModalOpen(true);
};
const handleChangePassword = (user: User) => {
setSelectedUser(user);
setIsChangePasswordModalOpen(true);
};
const handleAddTokens = (user: User) => {
setSelectedUser(user);
setAddTokensForm({ quantity: 1, price_per_token: 5.0 });
setIsAddTokensModalOpen(true);
};
const handleToggleUserStatus = async (user: User) => {
try {
const token = localStorage.getItem("token");
await axios.patch(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/users/${user.id}/toggle-status`, {}, {
headers: {
Authorization: `Bearer ${token}`
}
});
await fetchUsers();
} catch (error) {
console.error("Failed to toggle user status:", error);
}
};
const filteredUsers = users.filter(user => {
const matchesSearch = user.first_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.last_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.email.toLowerCase().includes(searchQuery.toLowerCase());
const matchesRole = filterRole === "all" || user.role === filterRole;
const matchesStatus = filterStatus === "all" ||
(filterStatus === "active" && user.is_active) ||
(filterStatus === "inactive" && !user.is_active);
return matchesSearch && matchesRole && matchesStatus;
});
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading users...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">User Management</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Manage user accounts, permissions, and access
</p>
</div>
<button
onClick={() => setIsAddUserModalOpen(true)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span>Add User</span>
</button>
</div>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Search Users
</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by name or email..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Filter by Role
</label>
<select
value={filterRole}
onChange={(e) => setFilterRole(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Roles</option>
<option value="admin">Admin</option>
<option value="recruiter">Recruiter</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Filter by Status
</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
</div>
{/* Users Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Last Login
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredUsers.map((user) => (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="w-10 h-10 bg-gray-300 dark:bg-gray-600 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{user.first_name[0]}{user.last_name[0]}
</span>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{user.first_name} {user.last_name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{user.email}
</div>
{user.company_name && (
<div className="text-xs text-gray-400 dark:text-gray-500">
{user.company_name}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.role === 'admin'
? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300'
}`}>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.is_active
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300'
}`}>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{user.last_login_at ? formatDate(user.last_login_at) : 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{formatDate(user.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
<button
onClick={() => handleEditUser(user)}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
Edit
</button>
<button
onClick={() => handleChangePassword(user)}
className="text-yellow-600 hover:text-yellow-900 dark:text-yellow-400 dark:hover:text-yellow-300"
>
Password
</button>
<button
onClick={() => handleAddTokens(user)}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
>
Tokens
</button>
<button
onClick={() => handleToggleUserStatus(user)}
className={`${
user.is_active
? 'text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300'
: 'text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300'
}`}
>
{user.is_active ? 'Deactivate' : 'Activate'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Modals would go here - EditUserModal, AddUserModal, ChangePasswordModal, AddTokensModal */}
{/* For now, we'll add placeholder modals */}
{/* Edit User Modal */}
{isEditModalOpen && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Edit User: {selectedUser.first_name} {selectedUser.last_name}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Edit user functionality will be implemented here.
</p>
<div className="flex justify-end space-x-2">
<button
onClick={() => setIsEditModalOpen(false)}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
Cancel
</button>
<button
onClick={() => setIsEditModalOpen(false)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Save Changes
</button>
</div>
</div>
</div>
)}
{/* Add User Modal */}
{isAddUserModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Add New User
</h3>
{createUserError && (
<div className="mb-3 text-sm text-red-600 dark:text-red-400">
{createUserError}
</div>
)}
<div className="space-y-3 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First name</label>
<input
type="text"
value={addUserForm.first_name}
onChange={(e) => setAddUserForm({ ...addUserForm, first_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Jane"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last name</label>
<input
type="text"
value={addUserForm.last_name}
onChange={(e) => setAddUserForm({ ...addUserForm, last_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Doe"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input
type="email"
value={addUserForm.email}
onChange={(e) => setAddUserForm({ ...addUserForm, email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="jane.doe@company.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company (optional)</label>
<input
type="text"
value={addUserForm.company_name}
onChange={(e) => setAddUserForm({ ...addUserForm, company_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Acme Corp"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Role</label>
<select
value={addUserForm.role}
onChange={(e) => setAddUserForm({ ...addUserForm, role: e.target.value as 'admin' | 'recruiter' })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="recruiter">Recruiter</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<input
type="password"
value={addUserForm.password}
onChange={(e) => setAddUserForm({ ...addUserForm, password: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
<input
type="password"
value={addUserForm.confirm_password}
onChange={(e) => setAddUserForm({ ...addUserForm, confirm_password: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="••••••••"
/>
</div>
</div>
</div>
<div className="flex justify-end space-x-2">
<button
onClick={() => setIsAddUserModalOpen(false)}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
Cancel
</button>
<button
onClick={async () => {
setCreateUserError(null);
// Basic validation
if (!addUserForm.first_name || !addUserForm.last_name || !addUserForm.email || !addUserForm.password) {
setCreateUserError("Please fill in all required fields.");
return;
}
if (addUserForm.password.length < 8) {
setCreateUserError("Password must be at least 8 characters.");
return;
}
if (addUserForm.password !== addUserForm.confirm_password) {
setCreateUserError("Passwords do not match.");
return;
}
try {
setIsCreatingUser(true);
const token = localStorage.getItem("token");
await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/users`,
{
email: addUserForm.email,
password: addUserForm.password,
first_name: addUserForm.first_name,
last_name: addUserForm.last_name,
company_name: addUserForm.company_name || undefined,
role: addUserForm.role
},
{ headers: { Authorization: `Bearer ${token}` } }
);
// Reset form and close
setAddUserForm({ first_name: "", last_name: "", email: "", company_name: "", role: "recruiter", password: "", confirm_password: "" });
setIsAddUserModalOpen(false);
await fetchUsers();
} catch (error: any) {
console.error("Failed to create user:", error);
const message = error?.response?.data?.message || error?.message || "Failed to create user";
setCreateUserError(message);
} finally {
setIsCreatingUser(false);
}
}}
disabled={isCreatingUser}
className={`px-4 py-2 rounded-lg text-white ${isCreatingUser ? 'bg-blue-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'}`}
>
{isCreatingUser ? 'Creating...' : 'Create User'}
</button>
</div>
</div>
</div>
)}
{/* Change Password Modal */}
{isChangePasswordModalOpen && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Change Password for {selectedUser.first_name} {selectedUser.last_name}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Change password functionality will be implemented here.
</p>
<div className="flex justify-end space-x-2">
<button
onClick={() => setIsChangePasswordModalOpen(false)}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
Cancel
</button>
<button
onClick={() => setIsChangePasswordModalOpen(false)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Update Password
</button>
</div>
</div>
</div>
)}
{/* Add Tokens Modal */}
{isAddTokensModalOpen && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Add Tokens to {selectedUser.first_name} {selectedUser.last_name}
</h3>
<div className="space-y-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Quantity</label>
<input
type="number"
min={1}
value={addTokensForm.quantity}
onChange={(e) => setAddTokensForm({ ...addTokensForm, quantity: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Price per token ($)</label>
<input
type="number"
step="0.01"
min={0}
value={addTokensForm.price_per_token}
onChange={(e) => setAddTokensForm({ ...addTokensForm, price_per_token: Math.max(0, parseFloat(e.target.value) || 0) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Total: <span className="font-semibold text-gray-900 dark:text-white">${(addTokensForm.quantity * addTokensForm.price_per_token).toFixed(2)}</span>
</div>
</div>
<div className="flex justify-end space-x-2">
<button
onClick={() => setIsAddTokensModalOpen(false)}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
Cancel
</button>
<button
onClick={async () => {
try {
const token = localStorage.getItem("token");
await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/add-tokens`,
{
user_id: selectedUser.id,
quantity: addTokensForm.quantity,
price_per_token: addTokensForm.price_per_token,
total_price: addTokensForm.quantity * addTokensForm.price_per_token,
},
{ headers: { Authorization: `Bearer ${token}` } }
);
setIsAddTokensModalOpen(false);
await fetchUsers();
// Let the recruiter header refresh if they are logged in elsewhere
window.dispatchEvent(new CustomEvent('tokensUpdated'));
} catch (error) {
console.error("Failed to add tokens:", error);
}
}}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Add Tokens
</button>
</div>
</div>
</div>
)}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
export { default as Sidebar } from './Sidebar';
export { default as Header } from './Header';
export { default as JobCard } from './JobCard';
export { default as JobsList } from './JobsList';
export { default as Layout } from './Layout';
export { default as CreateJobModal } from './CreateJobModal';
export { default as ThemeToggle } from './ThemeToggle';
// Landing page components
export { default as AnimatedCounter } from './AnimatedCounter';
export { default as FeatureCard } from './FeatureCard';
export { default as PricingCard } from './PricingCard';
export { default as TechStackCard } from './TechStackCard';
// Interview components
export { default as ConsentScreen } from './ConsentScreen';
export { default as NameInputScreen } from './NameInputScreen';
export { default as MandatoryQuestionsScreen } from './MandatoryQuestionsScreen';
export { default as ChatScreen } from './ChatScreen';
// Admin components
export { default as AdminLayout } from './AdminLayout';
export { default as AdminSidebar } from './AdminSidebar';
export { default as AdminHeader } from './AdminHeader';
export { default as AdminDashboard } from './AdminDashboard';
export { default as UserManagement } from './UserManagement';
export { default as JobManagement } from './JobManagement';
export { default as TokenManagement } from './TokenManagement';
export { default as SystemStats } from './SystemStats';
+77
View File
@@ -0,0 +1,77 @@
// Shared types for the application
export interface Job {
id: string;
user_id?: string;
title: string;
description: string;
requirements: string;
skills_required?: string[];
location?: string;
employment_type: string;
experience_level: string;
salary_min?: number;
salary_max?: number;
currency: string;
status: string;
evaluation_criteria?: any;
interview_questions?: any[];
interview_style?: 'personal' | 'balanced' | 'technical';
application_deadline?: string;
icon?: string;
created_at: string;
updated_at: string;
// Metrics
total_interviews?: number;
interviews_completed?: number;
available_interviews?: number;
running_days?: number;
applications?: number;
user?: {
first_name: string;
last_name: string;
email: string;
company_name?: string;
};
}
export interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: 'admin' | 'recruiter';
company_name?: string;
avatar_url?: string;
is_active: boolean;
last_login_at?: string;
email_verified_at?: string;
created_at: string;
updated_at: string;
}
export interface Message {
id: string;
content: string;
sender: 'user' | 'ai';
timestamp: Date;
}
export interface JobLink {
id: string;
job_id: string;
url_slug: string;
tokens_available?: number;
tokens_used?: number;
created_at: string;
updated_at: string;
}
export interface InterviewState {
currentStep: 'consent' | 'mandatory' | 'chat' | 'complete';
job: Job | null;
jobLink: JobLink | null;
messages: Message[];
mandatoryAnswers: string[];
isComplete: boolean;
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}