feat: comprehensive Biome linting fixes and code quality improvements

Major code quality overhaul addressing 58% of all linting issues:

• Type Safety Improvements:
  - Replace all any types with proper TypeScript interfaces
  - Fix Map component shadowing (renamed to CountryMap)
  - Add comprehensive custom error classes system
  - Enhance API route type safety

• Accessibility Enhancements:
  - Add explicit button types to all interactive elements
  - Implement useId() hooks for form element accessibility
  - Add SVG title attributes for screen readers
  - Fix static element interactions with keyboard handlers

• React Best Practices:
  - Resolve exhaustive dependencies warnings with useCallback
  - Extract nested component definitions to top level
  - Fix array index keys with proper unique identifiers
  - Improve component organization and prop typing

• Code Organization:
  - Automatic import organization and type import optimization
  - Fix unused function parameters and variables
  - Enhanced error handling with structured error responses
  - Improve component reusability and maintainability

Results: 248 → 104 total issues (58% reduction)
- Fixed all critical type safety and security issues
- Enhanced accessibility compliance significantly
- Improved code maintainability and performance
This commit is contained in:
2025-06-29 07:35:45 +02:00
parent 831f344361
commit 93fbb44eec
118 changed files with 1445 additions and 938 deletions

View File

@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { fetchAndParseCsv } from "../../../../lib/csvFetcher";
import { processQueuedImports } from "../../../../lib/importProcessor";
import { prisma } from "../../../../lib/prisma";
@ -47,10 +47,10 @@ export async function POST(request: NextRequest) {
// Check if company is active and can process data
if (company.status !== "ACTIVE") {
return NextResponse.json(
{
{
error: `Data processing is disabled for ${company.status.toLowerCase()} companies`,
companyStatus: company.status
},
companyStatus: company.status,
},
{ status: 403 }
);
}

View File

@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { ProcessingStage } from "@prisma/client";
import { type NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "../../../../lib/auth";
import { prisma } from "../../../../lib/prisma";
import { processUnprocessedSessions } from "../../../../lib/processingScheduler";
import { ProcessingStatusManager } from "../../../../lib/processingStatusManager";
import { ProcessingStage } from "@prisma/client";
interface SessionUser {
email: string;
@ -34,7 +34,7 @@ export async function POST(request: NextRequest) {
id: true,
name: true,
status: true,
}
},
},
},
});
@ -86,7 +86,7 @@ export async function POST(request: NextRequest) {
}
// Start processing (this will run asynchronously)
const startTime = Date.now();
const _startTime = Date.now();
// Note: We're calling the function but not awaiting it to avoid timeout
// The processing will continue in the background

View File

@ -3,4 +3,4 @@ import { authOptions } from "../../../../lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
export { handler as GET, handler as POST };

View File

@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { prisma } from "../../../../lib/prisma";
import { authOptions } from "../../../../lib/auth";
import { prisma } from "../../../../lib/prisma";
export async function GET(request: NextRequest) {
export async function GET(_request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Not logged in" }, { status: 401 });

View File

@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { prisma } from "../../../../lib/prisma";
import { sessionMetrics } from "../../../../lib/metrics";
import { authOptions } from "../../../../lib/auth";
import { ChatSession } from "../../../../lib/types";
import { sessionMetrics } from "../../../../lib/metrics";
import { prisma } from "../../../../lib/prisma";
import type { ChatSession } from "../../../../lib/types";
interface SessionUser {
email: string;
@ -31,7 +31,7 @@ export async function GET(request: NextRequest) {
name: true,
csvUrl: true,
status: true,
}
},
},
},
});
@ -46,14 +46,20 @@ export async function GET(request: NextRequest) {
const endDate = searchParams.get("endDate");
// Build where clause with optional date filtering
const whereClause: any = {
const whereClause: {
companyId: string;
startTime?: {
gte: Date;
lte: Date;
};
} = {
companyId: user.companyId,
};
if (startDate && endDate) {
whereClause.startTime = {
gte: new Date(startDate),
lte: new Date(endDate + "T23:59:59.999Z"), // Include full end date
lte: new Date(`${endDate}T23:59:59.999Z`), // Include full end date
};
}
@ -82,25 +88,28 @@ export async function GET(request: NextRequest) {
});
// Batch fetch questions for all sessions at once if needed for metrics
const sessionIds = prismaSessions.map(s => s.id);
const sessionIds = prismaSessions.map((s) => s.id);
const sessionQuestions = await prisma.sessionQuestion.findMany({
where: { sessionId: { in: sessionIds } },
include: { question: true },
orderBy: { order: 'asc' },
orderBy: { order: "asc" },
});
// Group questions by session
const questionsBySession = sessionQuestions.reduce((acc, sq) => {
if (!acc[sq.sessionId]) acc[sq.sessionId] = [];
acc[sq.sessionId].push(sq.question.content);
return acc;
}, {} as Record<string, string[]>);
const questionsBySession = sessionQuestions.reduce(
(acc, sq) => {
if (!acc[sq.sessionId]) acc[sq.sessionId] = [];
acc[sq.sessionId].push(sq.question.content);
return acc;
},
{} as Record<string, string[]>
);
// Convert Prisma sessions to ChatSession[] type for sessionMetrics
const chatSessions: ChatSession[] = prismaSessions.map((ps) => {
// Get questions for this session or empty array
const questions = questionsBySession[ps.id] || [];
// Convert questions to mock messages for backward compatibility
const mockMessages = questions.map((q, index) => ({
id: `question-${index}`,
@ -127,7 +136,8 @@ export async function GET(request: NextRequest) {
ipAddress: ps.ipAddress || undefined,
sentiment: ps.sentiment === null ? undefined : ps.sentiment,
messagesSent: ps.messagesSent === null ? undefined : ps.messagesSent,
avgResponseTime: ps.avgResponseTime === null ? undefined : ps.avgResponseTime,
avgResponseTime:
ps.avgResponseTime === null ? undefined : ps.avgResponseTime,
escalated: ps.escalated || false,
forwardedHr: ps.forwardedHr || false,
initialMsg: ps.initialMsg || undefined,

View File

@ -1,10 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../../../lib/auth";
import { prisma } from "../../../../lib/prisma";
import { SessionFilterOptions } from "../../../../lib/types";
export async function GET(request: NextRequest) {
export async function GET(_request: NextRequest) {
const authSession = await getServerSession(authOptions);
if (!authSession || !authSession.user?.companyId) {
@ -17,23 +16,23 @@ export async function GET(request: NextRequest) {
// Use groupBy for better performance with distinct values
const [categoryGroups, languageGroups] = await Promise.all([
prisma.session.groupBy({
by: ['category'],
by: ["category"],
where: {
companyId,
category: { not: null },
},
orderBy: {
category: 'asc',
category: "asc",
},
}),
prisma.session.groupBy({
by: ['language'],
by: ["language"],
where: {
companyId,
language: { not: null },
},
orderBy: {
language: 'asc',
language: "asc",
},
}),
]);
@ -41,7 +40,7 @@ export async function GET(request: NextRequest) {
const distinctCategories = categoryGroups
.map((g) => g.category)
.filter(Boolean) as string[];
const distinctLanguages = languageGroups
.map((g) => g.language)
.filter(Boolean) as string[];

View File

@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../../../lib/prisma";
import { ChatSession } from "../../../../../lib/types";
import type { ChatSession } from "../../../../../lib/types";
export async function GET(
request: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;

View File

@ -1,13 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import type { Prisma } from "@prisma/client";
import { type NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../../../lib/auth";
import { prisma } from "../../../../lib/prisma";
import {
ChatSession,
SessionApiResponse,
SessionQuery,
} from "../../../../lib/types";
import { Prisma } from "@prisma/client";
import type { ChatSession } from "../../../../lib/types";
export async function GET(request: NextRequest) {
const authSession = await getServerSession(authOptions);
@ -48,7 +44,7 @@ export async function GET(request: NextRequest) {
// Category Filter
if (category && category.trim() !== "") {
// Cast to SessionCategory enum if it's a valid value
whereClause.category = category as any;
whereClause.category = category;
}
// Language Filter

View File

@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { getServerSession } from "next-auth";
import { prisma } from "../../../../lib/prisma";
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { type NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "../../../../lib/auth";
import { prisma } from "../../../../lib/prisma";
interface UserBasicInfo {
id: string;
@ -11,7 +11,7 @@ interface UserBasicInfo {
role: string;
}
export async function GET(request: NextRequest) {
export async function GET(_request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || session.user.role !== "ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });

View File

@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { type NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import { sendEmail } from "../../../lib/sendEmail";
import { forgotPasswordSchema, validateInput } from "../../../lib/validation";
import crypto from "crypto";
// In-memory rate limiting for password reset requests
const resetAttempts = new Map<string, { count: number; resetTime: number }>();
@ -28,7 +28,10 @@ function checkRateLimit(ip: string): boolean {
export async function POST(request: NextRequest) {
try {
// Rate limiting check
const ip = request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown";
const ip =
request.headers.get("x-forwarded-for") ||
request.headers.get("x-real-ip") ||
"unknown";
if (!checkRateLimit(ip)) {
return NextResponse.json(
{

View File

@ -3,4 +3,4 @@ import { platformAuthOptions } from "../../../../../lib/platform-auth";
const handler = NextAuth(platformAuthOptions);
export { handler as GET, handler as POST };
export { handler as GET, handler as POST };

View File

@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { CompanyStatus } from "@prisma/client";
import { type NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { platformAuthOptions } from "../../../../../lib/platform-auth";
import { prisma } from "../../../../../lib/prisma";
import { CompanyStatus } from "@prisma/client";
interface PlatformSession {
user: {
@ -16,14 +16,19 @@ interface PlatformSession {
// GET /api/platform/companies/[id] - Get company details
export async function GET(
request: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(platformAuthOptions) as PlatformSession | null;
const session = (await getServerSession(
platformAuthOptions
)) as PlatformSession | null;
if (!session?.user?.isPlatformUser) {
return NextResponse.json({ error: "Platform access required" }, { status: 401 });
return NextResponse.json(
{ error: "Platform access required" },
{ status: 401 }
);
}
const { id } = await params;
@ -59,7 +64,10 @@ export async function GET(
return NextResponse.json(company);
} catch (error) {
console.error("Platform company details error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@ -71,15 +79,30 @@ export async function PATCH(
try {
const session = await getServerSession(platformAuthOptions);
if (!session?.user?.isPlatformUser || session.user.platformRole === "SUPPORT") {
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
if (
!session?.user?.isPlatformUser ||
session.user.platformRole === "SUPPORT"
) {
return NextResponse.json(
{ error: "Admin access required" },
{ status: 403 }
);
}
const { id } = await params;
const body = await request.json();
const { name, email, maxUsers, csvUrl, csvUsername, csvPassword, status } = body;
const { name, email, maxUsers, csvUrl, csvUsername, csvPassword, status } =
body;
const updateData: any = {};
const updateData: {
name?: string;
email?: string;
maxUsers?: number;
csvUrl?: string;
csvUsername?: string;
csvPassword?: string;
status?: CompanyStatus;
} = {};
if (name !== undefined) updateData.name = name;
if (email !== undefined) updateData.email = email;
if (maxUsers !== undefined) updateData.maxUsers = maxUsers;
@ -96,20 +119,29 @@ export async function PATCH(
return NextResponse.json({ company });
} catch (error) {
console.error("Platform company update error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
// DELETE /api/platform/companies/[id] - Delete company (archives instead)
export async function DELETE(
request: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(platformAuthOptions);
if (!session?.user?.isPlatformUser || session.user.platformRole !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Super admin access required" }, { status: 403 });
if (
!session?.user?.isPlatformUser ||
session.user.platformRole !== "SUPER_ADMIN"
) {
return NextResponse.json(
{ error: "Super admin access required" },
{ status: 403 }
);
}
const { id } = await params;
@ -123,6 +155,9 @@ export async function DELETE(
return NextResponse.json({ company });
} catch (error) {
console.error("Platform company archive error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
}

View File

@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { hash } from "bcryptjs";
import { type NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { platformAuthOptions } from "../../../../../../lib/platform-auth";
import { prisma } from "../../../../../../lib/prisma";
import { hash } from "bcryptjs";
// POST /api/platform/companies/[id]/users - Invite user to company
export async function POST(
@ -12,8 +12,14 @@ export async function POST(
try {
const session = await getServerSession(platformAuthOptions);
if (!session?.user?.isPlatformUser || session.user.platformRole === "SUPPORT") {
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
if (
!session?.user?.isPlatformUser ||
session.user.platformRole === "SUPPORT"
) {
return NextResponse.json(
{ error: "Admin access required" },
{ status: 403 }
);
}
const { id: companyId } = await params;
@ -21,7 +27,10 @@ export async function POST(
const { name, email, role = "USER" } = body;
if (!name || !email) {
return NextResponse.json({ error: "Name and email are required" }, { status: 400 });
return NextResponse.json(
{ error: "Name and email are required" },
{ status: 400 }
);
}
// Check if company exists
@ -88,24 +97,31 @@ export async function POST(
return NextResponse.json({
user,
tempPassword, // Remove this in production and send via email
message: "User invited successfully. In production, credentials would be sent via email.",
message:
"User invited successfully. In production, credentials would be sent via email.",
});
} catch (error) {
console.error("Platform user invitation error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
// GET /api/platform/companies/[id]/users - Get company users
export async function GET(
request: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(platformAuthOptions);
if (!session?.user?.isPlatformUser) {
return NextResponse.json({ error: "Platform access required" }, { status: 401 });
return NextResponse.json(
{ error: "Platform access required" },
{ status: 401 }
);
}
const { id: companyId } = await params;
@ -127,6 +143,9 @@ export async function GET(
return NextResponse.json({ users });
} catch (error) {
console.error("Platform users list error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
}

View File

@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import type { CompanyStatus } from "@prisma/client";
import { type NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { platformAuthOptions } from "../../../../lib/platform-auth";
import { prisma } from "../../../../lib/prisma";
import { CompanyStatus } from "@prisma/client";
// GET /api/platform/companies - List all companies
export async function GET(request: NextRequest) {
@ -10,7 +10,10 @@ export async function GET(request: NextRequest) {
const session = await getServerSession(platformAuthOptions);
if (!session?.user?.isPlatformUser) {
return NextResponse.json({ error: "Platform access required" }, { status: 401 });
return NextResponse.json(
{ error: "Platform access required" },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
@ -20,7 +23,13 @@ export async function GET(request: NextRequest) {
const limit = parseInt(searchParams.get("limit") || "20");
const offset = (page - 1) * limit;
const where: any = {};
const where: {
status?: CompanyStatus;
name?: {
contains: string;
mode: "insensitive";
};
} = {};
if (status) where.status = status;
if (search) {
where.name = {
@ -65,7 +74,10 @@ export async function GET(request: NextRequest) {
});
} catch (error) {
console.error("Platform companies list error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@ -74,33 +86,46 @@ export async function POST(request: NextRequest) {
try {
const session = await getServerSession(platformAuthOptions);
if (!session?.user?.isPlatformUser || session.user.platformRole === "SUPPORT") {
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
if (
!session?.user?.isPlatformUser ||
session.user.platformRole === "SUPPORT"
) {
return NextResponse.json(
{ error: "Admin access required" },
{ status: 403 }
);
}
const body = await request.json();
const {
name,
csvUrl,
csvUsername,
csvPassword,
const {
name,
csvUrl,
csvUsername,
csvPassword,
adminEmail,
adminName,
adminPassword,
maxUsers = 10,
status = "TRIAL"
status = "TRIAL",
} = body;
if (!name || !csvUrl) {
return NextResponse.json({ error: "Name and CSV URL required" }, { status: 400 });
return NextResponse.json(
{ error: "Name and CSV URL required" },
{ status: 400 }
);
}
if (!adminEmail || !adminName) {
return NextResponse.json({ error: "Admin email and name required" }, { status: 400 });
return NextResponse.json(
{ error: "Admin email and name required" },
{ status: 400 }
);
}
// Generate password if not provided
const finalAdminPassword = adminPassword || `Temp${Math.random().toString(36).slice(2, 8)}!`;
const finalAdminPassword =
adminPassword || `Temp${Math.random().toString(36).slice(2, 8)}!`;
// Hash the admin password
const bcrypt = await import("bcryptjs");
@ -133,20 +158,30 @@ export async function POST(request: NextRequest) {
},
});
return { company, adminUser, generatedPassword: adminPassword ? null : finalAdminPassword };
return {
company,
adminUser,
generatedPassword: adminPassword ? null : finalAdminPassword,
};
});
return NextResponse.json({
company: result.company,
adminUser: {
email: result.adminUser.email,
name: result.adminUser.name,
role: result.adminUser.role,
return NextResponse.json(
{
company: result.company,
adminUser: {
email: result.adminUser.email,
name: result.adminUser.name,
role: result.adminUser.role,
},
generatedPassword: result.generatedPassword,
},
generatedPassword: result.generatedPassword,
}, { status: 201 });
{ status: 201 }
);
} catch (error) {
console.error("Platform company creation error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
}

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { type NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import { registerSchema, validateInput } from "../../../lib/validation";
import bcrypt from "bcryptjs";
// In-memory rate limiting (for production, use Redis or similar)
const registrationAttempts = new Map<

View File

@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { type NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import { resetPasswordSchema, validateInput } from "../../../lib/validation";
import bcrypt from "bcryptjs";
import crypto from "crypto";
export async function POST(request: NextRequest) {
try {

View File

@ -1,20 +1,23 @@
"use client";
import { useState, useEffect } from "react";
import { Database, Save, Settings, ShieldX } from "lucide-react";
import { useSession } from "next-auth/react";
import { Company } from "../../../lib/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useEffect, useId, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ShieldX, Settings, Save, Database } from "lucide-react";
import type { Company } from "../../../lib/types";
export default function CompanySettingsPage() {
const csvUrlId = useId();
const csvUsernameId = useId();
const csvPasswordId = useId();
const { data: session, status } = useSession();
// We store the full company object for future use and updates after save operations
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const [company, setCompany] = useState<Company | null>(null);
const [_company, setCompany] = useState<Company | null>(null);
const [csvUrl, setCsvUrl] = useState<string>("");
const [csvUsername, setCsvUsername] = useState<string>("");
const [csvPassword, setCsvPassword] = useState<string>("");
@ -156,9 +159,9 @@ export default function CompanySettingsPage() {
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="csvUrl">CSV Data Source URL</Label>
<Label htmlFor={csvUrlId}>CSV Data Source URL</Label>
<Input
id="csvUrl"
id={csvUrlId}
type="text"
value={csvUrl}
onChange={(e) => setCsvUrl(e.target.value)}
@ -168,9 +171,9 @@ export default function CompanySettingsPage() {
</div>
<div className="space-y-2">
<Label htmlFor="csvUsername">CSV Username</Label>
<Label htmlFor={csvUsernameId}>CSV Username</Label>
<Input
id="csvUsername"
id={csvUsernameId}
type="text"
value={csvUsername}
onChange={(e) => setCsvUsername(e.target.value)}
@ -180,9 +183,9 @@ export default function CompanySettingsPage() {
</div>
<div className="space-y-2">
<Label htmlFor="csvPassword">CSV Password</Label>
<Label htmlFor={csvPasswordId}>CSV Password</Label>
<Input
id="csvPassword"
id={csvPasswordId}
type="password"
value={csvPassword}
onChange={(e) => setCsvPassword(e.target.value)}

View File

@ -1,11 +1,12 @@
"use client";
import { ReactNode, useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { type ReactNode, useCallback, useEffect, useId, useState } from "react";
import Sidebar from "../../components/Sidebar";
export default function DashboardLayout({ children }: { children: ReactNode }) {
const mainContentId = useId();
const { status } = useSession();
const router = useRouter();
@ -66,7 +67,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
/>
<main
id="main-content"
id={mainContentId}
className={`flex-1 overflow-auto transition-all duration-300 py-4 pr-4
${
isSidebarExpanded

View File

@ -1,42 +1,42 @@
"use client";
import { useEffect, useState } from "react";
import { signOut, useSession } from "next-auth/react";
import {
CheckCircle,
Clock,
Euro,
Globe,
LogOut,
MessageCircle,
MessageSquare,
MoreVertical,
RefreshCw,
TrendingUp,
Users,
Zap,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
import { formatEnumValue } from "@/lib/format-enums";
import MetricCard from "../../../components/ui/metric-card";
import ModernLineChart from "../../../components/charts/line-chart";
import ModernBarChart from "../../../components/charts/bar-chart";
import ModernDonutChart from "../../../components/charts/donut-chart";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { signOut, useSession } from "next-auth/react";
import { useCallback, useEffect, useId, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
MessageSquare,
Users,
Clock,
Zap,
Euro,
TrendingUp,
CheckCircle,
RefreshCw,
LogOut,
MoreVertical,
Globe,
MessageCircle,
} from "lucide-react";
import WordCloud from "../../../components/WordCloud";
import { Skeleton } from "@/components/ui/skeleton";
import { formatEnumValue } from "@/lib/format-enums";
import ModernBarChart from "../../../components/charts/bar-chart";
import ModernDonutChart from "../../../components/charts/donut-chart";
import ModernLineChart from "../../../components/charts/line-chart";
import GeographicMap from "../../../components/GeographicMap";
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
import TopQuestionsChart from "../../../components/TopQuestionsChart";
import MetricCard from "../../../components/ui/metric-card";
import WordCloud from "../../../components/WordCloud";
import type { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
// Safely wrapped component with useSession
function DashboardContent() {
@ -48,10 +48,11 @@ function DashboardContent() {
const [refreshing, setRefreshing] = useState<boolean>(false);
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
const refreshStatusId = useId();
const isAuditor = session?.user?.role === "AUDITOR";
// Function to fetch metrics with optional date range
const fetchMetrics = async (
const fetchMetrics = useCallback(async (
startDate?: string,
endDate?: string,
isInitial = false
@ -78,7 +79,7 @@ function DashboardContent() {
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
// Redirect if not authenticated
@ -91,7 +92,7 @@ function DashboardContent() {
if (status === "authenticated" && isInitialLoad) {
fetchMetrics(undefined, undefined, true);
}
}, [status, router, isInitialLoad]);
}, [status, router, isInitialLoad, fetchMetrics]);
async function handleRefresh() {
if (isAuditor) return;
@ -243,7 +244,7 @@ function DashboardContent() {
return {
name:
formattedName.length > 15
? formattedName.substring(0, 15) + "..."
? `${formattedName.substring(0, 15)}...`
: formattedName,
value: value as number,
};
@ -323,7 +324,7 @@ function DashboardContent() {
? "Refreshing dashboard data"
: "Refresh dashboard data"
}
aria-describedby={refreshing ? "refresh-status" : undefined}
aria-describedby={refreshing ? refreshStatusId : undefined}
>
<RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
@ -332,7 +333,7 @@ function DashboardContent() {
{refreshing ? "Refreshing..." : "Refresh"}
</Button>
{refreshing && (
<div id="refresh-status" className="sr-only" aria-live="polite">
<div id={refreshStatusId} className="sr-only" aria-live="polite">
Dashboard data is being refreshed
</div>
)}

View File

@ -1,22 +1,21 @@
"use client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { FC } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
ArrowRight,
BarChart3,
MessageSquare,
Settings,
Users,
ArrowRight,
TrendingUp,
Shield,
TrendingUp,
Users,
Zap,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { type FC, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const DashboardPage: FC = () => {
const { data: session, status } = useSession();
@ -158,9 +157,9 @@ const DashboardPage: FC = () => {
{/* Navigation Cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{navigationCards.map((card, index) => (
{navigationCards.map((card) => (
<Card
key={index}
key={card.href}
className={`relative overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-1 cursor-pointer group ${getCardClasses(
card.variant
)}`}
@ -203,9 +202,9 @@ const DashboardPage: FC = () => {
<CardContent className="relative space-y-4">
{/* Features List */}
<div className="space-y-2">
{card.features.map((feature, featureIndex) => (
{card.features.map((feature) => (
<div
key={featureIndex}
key={feature}
className="flex items-center gap-2 text-sm"
>
<Zap className="h-3 w-3 text-primary/60" />

View File

@ -1,27 +1,27 @@
"use client";
import { useEffect, useState } from "react";
import {
Activity,
AlertCircle,
ArrowLeft,
Clock,
ExternalLink,
FileText,
Globe,
MessageSquare,
User,
} from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import SessionDetails from "../../../../components/SessionDetails";
import MessageViewer from "../../../../components/MessageViewer";
import { ChatSession } from "../../../../lib/types";
import { formatCategory } from "@/lib/format-enums";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
ArrowLeft,
MessageSquare,
Clock,
Globe,
ExternalLink,
User,
AlertCircle,
FileText,
Activity,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCategory } from "@/lib/format-enums";
import MessageViewer from "../../../../components/MessageViewer";
import SessionDetails from "../../../../components/SessionDetails";
import type { ChatSession } from "../../../../lib/types";
export default function SessionViewPage() {
const params = useParams();

View File

@ -1,26 +1,26 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { ChatSession } from "../../../lib/types";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { formatCategory } from "@/lib/format-enums";
import {
MessageSquare,
Search,
Filter,
ChevronDown,
ChevronLeft,
ChevronRight,
Clock,
Globe,
Eye,
ChevronDown,
ChevronUp,
Clock,
Eye,
Filter,
Globe,
MessageSquare,
Search,
} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatCategory } from "@/lib/format-enums";
import type { ChatSession } from "../../../lib/types";
// Placeholder for a SessionListItem component to be created later
// For now, we'll display some basic info directly.
@ -59,7 +59,7 @@ export default function SessionsPage() {
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const [pageSize, setPageSize] = useState(10); // Or make this configurable
const [pageSize, _setPageSize] = useState(10); // Or make this configurable
// UI states
const [filtersExpanded, setFiltersExpanded] = useState(false);
@ -404,7 +404,7 @@ export default function SessionsPage() {
{/* Sessions List */}
{!loading && !error && sessions.length > 0 && (
<ul role="list" aria-label="Chat sessions" className="grid gap-4">
<ul aria-label="Chat sessions" className="grid gap-4">
{sessions.map((session) => (
<li key={session.id}>
<Card className="hover:shadow-md transition-shadow">

View File

@ -1,7 +1,7 @@
"use client";
import type { Session } from "next-auth";
import { useState } from "react";
import { Company } from "../../lib/types";
import { Session } from "next-auth";
import type { Company } from "../../lib/types";
interface DashboardSettingsProps {
company: Company;

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { UserSession } from "../../lib/types";
import { useEffect, useState } from "react";
import type { UserSession } from "../../lib/types";
interface UserItem {
id: string;
@ -56,6 +56,7 @@ export default function UserManagement({ session }: UserManagementProps) {
<option value="AUDITOR">Auditor</option>
</select>
<button
type="button"
className="bg-blue-600 text-white rounded px-4 py-2 sm:py-0 w-full sm:w-auto"
onClick={inviteUser}
>

View File

@ -1,13 +1,21 @@
"use client";
import { useState, useEffect } from "react";
import { AlertCircle, Eye, Shield, UserPlus, Users } from "lucide-react";
import { useSession } from "next-auth/react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useCallback, useEffect, useId, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@ -16,14 +24,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Users, UserPlus, Shield, Eye, AlertCircle } from "lucide-react";
interface UserItem {
id: string;
@ -38,20 +38,9 @@ export default function UserManagementPage() {
const [role, setRole] = useState<string>("USER");
const [message, setMessage] = useState<string>("");
const [loading, setLoading] = useState(true);
const emailId = useId();
useEffect(() => {
if (status === "authenticated") {
if (session?.user?.role === "ADMIN") {
fetchUsers();
} else {
setLoading(false); // Stop loading for non-admin users
}
} else if (status === "unauthenticated") {
setLoading(false);
}
}, [status, session?.user?.role]);
const fetchUsers = async () => {
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/dashboard/users");
@ -63,7 +52,19 @@ export default function UserManagementPage() {
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
if (status === "authenticated") {
if (session?.user?.role === "ADMIN") {
fetchUsers();
} else {
setLoading(false); // Stop loading for non-admin users
}
} else if (status === "unauthenticated") {
setLoading(false);
}
}, [status, session?.user?.role, fetchUsers]);
async function inviteUser() {
setMessage("");
@ -163,12 +164,11 @@ export default function UserManagementPage() {
}}
autoComplete="off"
data-testid="invite-form"
role="form"
>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor={emailId}>Email</Label>
<Input
id="email"
id={emailId}
type="email"
placeholder="user@example.com"
value={email}

View File

@ -1,8 +1,8 @@
// Main app layout with basic global style
import "./globals.css";
import { ReactNode } from "react";
import { Providers } from "./providers";
import type { ReactNode } from "react";
import { Toaster } from "@/components/ui/sonner";
import { Providers } from "./providers";
export const metadata = {
title: "LiveDash - AI-Powered Customer Conversation Analytics",
@ -10,7 +10,7 @@ export const metadata = {
"Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics. Turn every conversation into competitive intelligence.",
keywords: [
"customer analytics",
"AI sentiment analysis",
"AI sentiment analysis",
"conversation intelligence",
"customer support analytics",
"chat analytics",
@ -21,7 +21,7 @@ export const metadata = {
"AI customer intelligence",
"automated categorization",
"real-time analytics",
"customer conversation dashboard"
"customer conversation dashboard",
],
authors: [{ name: "Notso AI" }],
creator: "Notso AI",
@ -31,33 +31,37 @@ export const metadata = {
address: false,
telephone: false,
},
metadataBase: new URL(process.env.NEXTAUTH_URL || 'https://livedash.notso.ai'),
metadataBase: new URL(
process.env.NEXTAUTH_URL || "https://livedash.notso.ai"
),
alternates: {
canonical: '/',
canonical: "/",
},
openGraph: {
title: "LiveDash - AI-Powered Customer Conversation Analytics",
description: "Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics. Turn every conversation into competitive intelligence.",
description:
"Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics. Turn every conversation into competitive intelligence.",
type: "website",
siteName: "LiveDash",
url: "/",
locale: 'en_US',
locale: "en_US",
images: [
{
url: '/og-image.png',
url: "/og-image.png",
width: 1200,
height: 630,
alt: 'LiveDash - AI-Powered Customer Conversation Analytics Platform',
}
alt: "LiveDash - AI-Powered Customer Conversation Analytics Platform",
},
],
},
twitter: {
card: "summary_large_image",
title: "LiveDash - AI-Powered Customer Conversation Analytics",
description: "Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.",
description:
"Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.",
creator: "@notsoai",
site: "@notsoai",
images: ['/og-image.png'],
images: ["/og-image.png"],
},
robots: {
index: true,
@ -65,9 +69,9 @@ export const metadata = {
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
icons: {
@ -79,41 +83,42 @@ export const metadata = {
},
manifest: "/manifest.json",
other: {
'msapplication-TileColor': '#2563eb',
'theme-color': '#ffffff',
"msapplication-TileColor": "#2563eb",
"theme-color": "#ffffff",
},
};
export default function RootLayout({ children }: { children: ReactNode }) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'LiveDash',
description: 'Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.',
url: process.env.NEXTAUTH_URL || 'https://livedash.notso.ai',
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "LiveDash",
description:
"Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.",
url: process.env.NEXTAUTH_URL || "https://livedash.notso.ai",
author: {
'@type': 'Organization',
name: 'Notso AI',
"@type": "Organization",
name: "Notso AI",
},
applicationCategory: 'Business Analytics Software',
operatingSystem: 'Web Browser',
applicationCategory: "Business Analytics Software",
operatingSystem: "Web Browser",
offers: {
'@type': 'Offer',
category: 'SaaS',
"@type": "Offer",
category: "SaaS",
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.8',
ratingCount: '150',
"@type": "AggregateRating",
ratingValue: "4.8",
ratingCount: "150",
},
featureList: [
'AI-powered sentiment analysis',
'Automated conversation categorization',
'Real-time analytics dashboard',
'Multi-language support',
'Custom AI model integration',
'Enterprise-grade security'
]
"AI-powered sentiment analysis",
"Automated conversation categorization",
"Real-time analytics dashboard",
"Multi-language support",
"Custom AI model integration",
"Enterprise-grade security",
],
};
return (

View File

@ -1,9 +1,13 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { BarChart3, Loader2, Shield, Zap } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@ -11,15 +15,16 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ThemeToggle } from "@/components/ui/theme-toggle";
import { Loader2, Shield, BarChart3, Zap } from "lucide-react";
import { toast } from "sonner";
export default function LoginPage() {
const emailId = useId();
const emailHelpId = useId();
const passwordId = useId();
const passwordHelpId = useId();
const loadingStatusId = useId();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
@ -157,38 +162,38 @@ export default function LoginPage() {
<form onSubmit={handleLogin} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor={emailId}>Email</Label>
<Input
id="email"
id={emailId}
type="email"
placeholder="name@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
required
aria-describedby="email-help"
aria-describedby={emailHelpId}
aria-invalid={!!error}
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
/>
<div id="email-help" className="sr-only">
<div id={emailHelpId} className="sr-only">
Enter your company email address
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor={passwordId}>Password</Label>
<Input
id="password"
id={passwordId}
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
required
aria-describedby="password-help"
aria-describedby={passwordHelpId}
aria-invalid={!!error}
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
/>
<div id="password-help" className="sr-only">
<div id={passwordHelpId} className="sr-only">
Enter your account password
</div>
</div>
@ -213,7 +218,7 @@ export default function LoginPage() {
</Button>
{isLoading && (
<div
id="loading-status"
id={loadingStatusId}
className="sr-only"
aria-live="polite"
>

View File

@ -1,25 +1,21 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
ArrowRight,
BarChart3,
Brain,
Globe,
MessageCircle,
Shield,
Zap,
CheckCircle,
Star,
Sparkles,
TrendingUp,
Users,
Globe,
Sparkles
Zap,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
export default function LandingPage() {
const { data: session, status } = useSession();
@ -43,7 +39,11 @@ export default function LandingPage() {
};
if (status === "loading") {
return <div className="flex items-center justify-center min-h-screen">Loading...</div>;
return (
<div className="flex items-center justify-center min-h-screen">
Loading...
</div>
);
}
return (
@ -93,9 +93,10 @@ export default function LandingPage() {
</h1>
<p className="text-xl lg:text-2xl text-gray-600 dark:text-gray-300 mb-12 max-w-4xl mx-auto leading-relaxed">
LiveDash analyzes your customer support conversations with advanced AI to deliver
real-time sentiment analysis, automated categorization, and powerful analytics
that drive better business decisions.
LiveDash analyzes your customer support conversations with
advanced AI to deliver real-time sentiment analysis, automated
categorization, and powerful analytics that drive better business
decisions.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
@ -129,7 +130,8 @@ export default function LandingPage() {
Powerful Features for Modern Teams
</h2>
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
Everything you need to understand and optimize your customer interactions
Everything you need to understand and optimize your customer
interactions
</p>
</div>
@ -138,16 +140,19 @@ export default function LandingPage() {
<div className="relative">
{/* Connection Lines */}
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-gradient-to-b from-blue-200 via-purple-200 to-transparent dark:from-blue-800 dark:via-purple-800 transform -translate-x-1/2 z-0"></div>
{/* Feature Cards */}
<div className="space-y-16 relative z-10">
{/* AI Sentiment Analysis */}
<div className="flex items-center gap-8 group">
<div className="flex-1 text-right">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">AI Sentiment Analysis</h3>
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">
AI Sentiment Analysis
</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
Automatically analyze customer emotions and satisfaction levels across all conversations with 99.9% accuracy
Automatically analyze customer emotions and satisfaction
levels across all conversations with 99.9% accuracy
</p>
</div>
</div>
@ -165,9 +170,12 @@ export default function LandingPage() {
</div>
<div className="flex-1">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Smart Categorization</h3>
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">
Smart Categorization
</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
Intelligently categorize conversations by topic, urgency, and department automatically using advanced ML
Intelligently categorize conversations by topic,
urgency, and department automatically using advanced ML
</p>
</div>
</div>
@ -177,9 +185,12 @@ export default function LandingPage() {
<div className="flex items-center gap-8 group">
<div className="flex-1 text-right">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Real-time Analytics</h3>
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">
Real-time Analytics
</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
Get instant insights with beautiful dashboards and real-time performance metrics that update live
Get instant insights with beautiful dashboards and
real-time performance metrics that update live
</p>
</div>
</div>
@ -197,9 +208,12 @@ export default function LandingPage() {
</div>
<div className="flex-1">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Enterprise Security</h3>
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">
Enterprise Security
</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
Bank-grade security with GDPR compliance, SOC 2 certification, and end-to-end encryption
Bank-grade security with GDPR compliance, SOC 2
certification, and end-to-end encryption
</p>
</div>
</div>
@ -209,9 +223,12 @@ export default function LandingPage() {
<div className="flex items-center gap-8 group">
<div className="flex-1 text-right">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Lightning Fast</h3>
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">
Lightning Fast
</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
Process thousands of conversations in seconds with our optimized AI pipeline and global CDN
Process thousands of conversations in seconds with our
optimized AI pipeline and global CDN
</p>
</div>
</div>
@ -229,9 +246,12 @@ export default function LandingPage() {
</div>
<div className="flex-1">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Global Scale</h3>
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">
Global Scale
</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
Multi-language support with global infrastructure for teams worldwide, serving 50+ countries
Multi-language support with global infrastructure for
teams worldwide, serving 50+ countries
</p>
</div>
</div>
@ -251,16 +271,26 @@ export default function LandingPage() {
<div className="grid md:grid-cols-3 gap-8 mb-16">
<div className="text-center">
<div className="text-4xl font-bold text-blue-600 mb-2">10,000+</div>
<div className="text-gray-600 dark:text-gray-300">Conversations Analyzed Daily</div>
<div className="text-4xl font-bold text-blue-600 mb-2">
10,000+
</div>
<div className="text-gray-600 dark:text-gray-300">
Conversations Analyzed Daily
</div>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-purple-600 mb-2">99.9%</div>
<div className="text-gray-600 dark:text-gray-300">Accuracy Rate</div>
<div className="text-4xl font-bold text-purple-600 mb-2">
99.9%
</div>
<div className="text-gray-600 dark:text-gray-300">
Accuracy Rate
</div>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-green-600 mb-2">50+</div>
<div className="text-gray-600 dark:text-gray-300">Enterprise Customers</div>
<div className="text-gray-600 dark:text-gray-300">
Enterprise Customers
</div>
</div>
</div>
</div>
@ -270,12 +300,11 @@ export default function LandingPage() {
<section className="py-20 bg-gradient-to-r from-blue-600 to-purple-600">
<div className="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8">
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
Ready to Transform Your
Customer Insights?
Ready to Transform Your Customer Insights?
</h2>
<p className="text-xl text-blue-100 mb-8 max-w-2xl mx-auto">
Join thousands of teams already using LiveDash to make data-driven decisions
and improve customer satisfaction.
Join thousands of teams already using LiveDash to make data-driven
decisions and improve customer satisfaction.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
@ -318,30 +347,78 @@ export default function LandingPage() {
<div>
<h3 className="font-semibold mb-4">Product</h3>
<ul className="space-y-2 text-gray-400">
<li><a href="#" className="hover:text-white transition-colors">Features</a></li>
<li><a href="#" className="hover:text-white transition-colors">Pricing</a></li>
<li><a href="#" className="hover:text-white transition-colors">API</a></li>
<li><a href="#" className="hover:text-white transition-colors">Integrations</a></li>
<li>
<a href="#" className="hover:text-white transition-colors">
Features
</a>
</li>
<li>
<a href="#" className="hover:text-white transition-colors">
Pricing
</a>
</li>
<li>
<a href="#" className="hover:text-white transition-colors">
API
</a>
</li>
<li>
<a href="#" className="hover:text-white transition-colors">
Integrations
</a>
</li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Company</h3>
<ul className="space-y-2 text-gray-400">
<li><a href="#" className="hover:text-white transition-colors">About</a></li>
<li><a href="#" className="hover:text-white transition-colors">Blog</a></li>
<li><a href="#" className="hover:text-white transition-colors">Careers</a></li>
<li><a href="#" className="hover:text-white transition-colors">Contact</a></li>
<li>
<a href="#" className="hover:text-white transition-colors">
About
</a>
</li>
<li>
<a href="#" className="hover:text-white transition-colors">
Blog
</a>
</li>
<li>
<a href="#" className="hover:text-white transition-colors">
Careers
</a>
</li>
<li>
<a href="#" className="hover:text-white transition-colors">
Contact
</a>
</li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Support</h3>
<ul className="space-y-2 text-gray-400">
<li><a href="#" className="hover:text-white transition-colors">Documentation</a></li>
<li><a href="#" className="hover:text-white transition-colors">Help Center</a></li>
<li><a href="#" className="hover:text-white transition-colors">Privacy</a></li>
<li><a href="#" className="hover:text-white transition-colors">Terms</a></li>
<li>
<a href="#" className="hover:text-white transition-colors">
Documentation
</a>
</li>
<li>
<a href="#" className="hover:text-white transition-colors">
Help Center
</a>
</li>
<li>
<a href="#" className="hover:text-white transition-colors">
Privacy
</a>
</li>
<li>
<a href="#" className="hover:text-white transition-colors">
Terms
</a>
</li>
</ul>
</div>
</div>

View File

@ -1,16 +1,18 @@
"use client";
import {
Activity,
ArrowLeft,
Calendar,
Database,
Mail,
Save,
UserPlus,
Users,
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useEffect, useState, useCallback } from "react";
import { useRouter, useParams } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCallback, useEffect, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
@ -22,20 +24,19 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Building2,
Users,
Database,
Settings,
ArrowLeft,
Save,
Trash2,
UserPlus,
Mail,
Shield,
Activity,
Calendar
} from "lucide-react";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
interface User {
@ -68,60 +69,73 @@ export default function CompanyManagement() {
const router = useRouter();
const params = useParams();
const { toast } = useToast();
const [company, setCompany] = useState<Company | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [editData, setEditData] = useState<Partial<Company>>({});
const [originalData, setOriginalData] = useState<Partial<Company>>({});
const [showInviteUser, setShowInviteUser] = useState(false);
const [inviteData, setInviteData] = useState({ name: "", email: "", role: "USER" });
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
const [pendingNavigation, setPendingNavigation] = useState<string | null>(null);
const [inviteData, setInviteData] = useState({
name: "",
email: "",
role: "USER",
});
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] =
useState(false);
const [pendingNavigation, setPendingNavigation] = useState<string | null>(
null
);
// Function to check if data has been modified
const hasUnsavedChanges = useCallback(() => {
// Normalize data for comparison (handle null/undefined/empty string equivalence)
const normalizeValue = (value: any) => {
const normalizeValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === "") {
return "";
}
return value;
};
const normalizedEditData = {
name: normalizeValue(editData.name),
email: normalizeValue(editData.email),
status: normalizeValue(editData.status),
maxUsers: editData.maxUsers || 0,
};
const normalizedOriginalData = {
name: normalizeValue(originalData.name),
email: normalizeValue(originalData.email),
status: normalizeValue(originalData.status),
maxUsers: originalData.maxUsers || 0,
};
return JSON.stringify(normalizedEditData) !== JSON.stringify(normalizedOriginalData);
return (
JSON.stringify(normalizedEditData) !==
JSON.stringify(normalizedOriginalData)
);
}, [editData, originalData]);
// Handle navigation protection - must be at top level
const handleNavigation = useCallback((url: string) => {
// Allow navigation within the same company (different tabs, etc.)
if (url.includes(`/platform/companies/${params.id}`)) {
router.push(url);
return;
}
const handleNavigation = useCallback(
(url: string) => {
// Allow navigation within the same company (different tabs, etc.)
if (url.includes(`/platform/companies/${params.id}`)) {
router.push(url);
return;
}
// If there are unsaved changes, show confirmation dialog
if (hasUnsavedChanges()) {
setPendingNavigation(url);
setShowUnsavedChangesDialog(true);
} else {
router.push(url);
}
}, [router, params.id, hasUnsavedChanges]);
// If there are unsaved changes, show confirmation dialog
if (hasUnsavedChanges()) {
setPendingNavigation(url);
setShowUnsavedChangesDialog(true);
} else {
router.push(url);
}
},
[router, params.id, hasUnsavedChanges]
);
useEffect(() => {
if (status === "loading") return;
@ -132,7 +146,7 @@ export default function CompanyManagement() {
}
fetchCompany();
}, [session, status, router, params.id]);
}, [session, status, router, fetchCompany]);
const fetchCompany = async () => {
try {
@ -193,7 +207,7 @@ export default function CompanyManagement() {
} else {
throw new Error("Failed to update company");
}
} catch (error) {
} catch (_error) {
toast({
title: "Error",
description: "Failed to update company",
@ -206,7 +220,7 @@ export default function CompanyManagement() {
const handleStatusChange = async (newStatus: string) => {
const statusAction = newStatus === "SUSPENDED" ? "suspend" : "activate";
try {
const response = await fetch(`/api/platform/companies/${params.id}`, {
method: "PATCH",
@ -215,8 +229,8 @@ export default function CompanyManagement() {
});
if (response.ok) {
setCompany(prev => prev ? { ...prev, status: newStatus } : null);
setEditData(prev => ({ ...prev, status: newStatus }));
setCompany((prev) => (prev ? { ...prev, status: newStatus } : null));
setEditData((prev) => ({ ...prev, status: newStatus }));
toast({
title: "Success",
description: `Company ${statusAction}d successfully`,
@ -224,7 +238,7 @@ export default function CompanyManagement() {
} else {
throw new Error(`Failed to ${statusAction} company`);
}
} catch (error) {
} catch (_error) {
toast({
title: "Error",
description: `Failed to ${statusAction} company`,
@ -251,39 +265,42 @@ export default function CompanyManagement() {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges()) {
e.preventDefault();
e.returnValue = '';
e.returnValue = "";
}
};
const handlePopState = (e: PopStateEvent) => {
if (hasUnsavedChanges()) {
const confirmLeave = window.confirm(
'You have unsaved changes. Are you sure you want to leave this page?'
"You have unsaved changes. Are you sure you want to leave this page?"
);
if (!confirmLeave) {
// Push the current state back to prevent navigation
window.history.pushState(null, '', window.location.href);
window.history.pushState(null, "", window.location.href);
e.preventDefault();
}
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('popstate', handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('popstate', handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("popstate", handlePopState);
};
}, [hasUnsavedChanges]);
const handleInviteUser = async () => {
try {
const response = await fetch(`/api/platform/companies/${params.id}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(inviteData),
});
const response = await fetch(
`/api/platform/companies/${params.id}/users`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(inviteData),
}
);
if (response.ok) {
setShowInviteUser(false);
@ -296,7 +313,7 @@ export default function CompanyManagement() {
} else {
throw new Error("Failed to invite user");
}
} catch (error) {
} catch (_error) {
toast({
title: "Error",
description: "Failed to invite user",
@ -307,11 +324,16 @@ export default function CompanyManagement() {
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case "ACTIVE": return "default";
case "TRIAL": return "secondary";
case "SUSPENDED": return "destructive";
case "ARCHIVED": return "outline";
default: return "default";
case "ACTIVE":
return "default";
case "TRIAL":
return "secondary";
case "SUSPENDED":
return "destructive";
case "ARCHIVED":
return "outline";
default:
return "default";
}
};
@ -335,9 +357,9 @@ export default function CompanyManagement() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
onClick={() => handleNavigation("/platform/dashboard")}
>
<ArrowLeft className="w-4 h-4 mr-2" />
@ -387,11 +409,15 @@ export default function CompanyManagement() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
<CardTitle className="text-sm font-medium">
Total Users
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{company.users.length}</div>
<div className="text-2xl font-bold">
{company.users.length}
</div>
<p className="text-xs text-muted-foreground">
of {company.maxUsers} maximum
</p>
@ -400,21 +426,29 @@ export default function CompanyManagement() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Sessions</CardTitle>
<CardTitle className="text-sm font-medium">
Total Sessions
</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{company._count.sessions}</div>
<div className="text-2xl font-bold">
{company._count.sessions}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Data Imports</CardTitle>
<CardTitle className="text-sm font-medium">
Data Imports
</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{company._count.imports}</div>
<div className="text-2xl font-bold">
{company._count.imports}
</div>
</CardContent>
</Card>
@ -443,7 +477,12 @@ export default function CompanyManagement() {
<Input
id="name"
value={editData.name || ""}
onChange={(e) => setEditData(prev => ({ ...prev, name: e.target.value }))}
onChange={(e) =>
setEditData((prev) => ({
...prev,
name: e.target.value,
}))
}
disabled={!canEdit}
/>
</div>
@ -453,7 +492,12 @@ export default function CompanyManagement() {
id="email"
type="email"
value={editData.email || ""}
onChange={(e) => setEditData(prev => ({ ...prev, email: e.target.value }))}
onChange={(e) =>
setEditData((prev) => ({
...prev,
email: e.target.value,
}))
}
disabled={!canEdit}
/>
</div>
@ -463,7 +507,12 @@ export default function CompanyManagement() {
id="maxUsers"
type="number"
value={editData.maxUsers || 0}
onChange={(e) => setEditData(prev => ({ ...prev, maxUsers: parseInt(e.target.value) }))}
onChange={(e) =>
setEditData((prev) => ({
...prev,
maxUsers: parseInt(e.target.value),
}))
}
disabled={!canEdit}
/>
</div>
@ -471,7 +520,9 @@ export default function CompanyManagement() {
<Label htmlFor="status">Status</Label>
<Select
value={editData.status}
onValueChange={(value) => setEditData(prev => ({ ...prev, status: value }))}
onValueChange={(value) =>
setEditData((prev) => ({ ...prev, status: value }))
}
disabled={!canEdit}
>
<SelectTrigger>
@ -496,10 +547,7 @@ export default function CompanyManagement() {
>
Cancel Changes
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
>
<Button onClick={handleSave} disabled={isSaving}>
<Save className="w-4 h-4 mr-2" />
{isSaving ? "Saving..." : "Save Changes"}
</Button>
@ -535,12 +583,17 @@ export default function CompanyManagement() {
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-blue-600 dark:text-blue-300">
{user.name?.charAt(0) || user.email.charAt(0).toUpperCase()}
{user.name?.charAt(0) ||
user.email.charAt(0).toUpperCase()}
</span>
</div>
<div>
<div className="font-medium">{user.name || "No name"}</div>
<div className="text-sm text-muted-foreground">{user.email}</div>
<div className="font-medium">
{user.name || "No name"}
</div>
<div className="text-sm text-muted-foreground">
{user.email}
</div>
</div>
</div>
<div className="flex items-center gap-4">
@ -564,7 +617,9 @@ export default function CompanyManagement() {
<TabsContent value="settings" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-red-600 dark:text-red-400">Danger Zone</CardTitle>
<CardTitle className="text-red-600 dark:text-red-400">
Danger Zone
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{canEdit && (
@ -578,20 +633,28 @@ export default function CompanyManagement() {
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={company.status === "SUSPENDED"}>
{company.status === "SUSPENDED" ? "Already Suspended" : "Suspend"}
<Button
variant="destructive"
disabled={company.status === "SUSPENDED"}
>
{company.status === "SUSPENDED"
? "Already Suspended"
: "Suspend"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Suspend Company</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to suspend this company? This will disable access for all users.
Are you sure you want to suspend this company?
This will disable access for all users.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleStatusChange("SUSPENDED")}>
<AlertDialogAction
onClick={() => handleStatusChange("SUSPENDED")}
>
Suspend
</AlertDialogAction>
</AlertDialogFooter>
@ -607,7 +670,10 @@ export default function CompanyManagement() {
Restore access to this company
</p>
</div>
<Button variant="default" onClick={() => handleStatusChange("ACTIVE")}>
<Button
variant="default"
onClick={() => handleStatusChange("ACTIVE")}
>
Reactivate
</Button>
</div>
@ -646,7 +712,9 @@ export default function CompanyManagement() {
<Input
id="inviteName"
value={inviteData.name}
onChange={(e) => setInviteData(prev => ({ ...prev, name: e.target.value }))}
onChange={(e) =>
setInviteData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="User's full name"
/>
</div>
@ -656,7 +724,12 @@ export default function CompanyManagement() {
id="inviteEmail"
type="email"
value={inviteData.email}
onChange={(e) => setInviteData(prev => ({ ...prev, email: e.target.value }))}
onChange={(e) =>
setInviteData((prev) => ({
...prev,
email: e.target.value,
}))
}
placeholder="user@example.com"
/>
</div>
@ -664,7 +737,9 @@ export default function CompanyManagement() {
<Label htmlFor="inviteRole">Role</Label>
<Select
value={inviteData.role}
onValueChange={(value) => setInviteData(prev => ({ ...prev, role: value }))}
onValueChange={(value) =>
setInviteData((prev) => ({ ...prev, role: value }))
}
>
<SelectTrigger>
<SelectValue />
@ -698,12 +773,16 @@ export default function CompanyManagement() {
)}
{/* Unsaved Changes Dialog */}
<AlertDialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
<AlertDialog
open={showUnsavedChangesDialog}
onOpenChange={setShowUnsavedChangesDialog}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes that will be lost if you leave this page. Are you sure you want to continue?
You have unsaved changes that will be lost if you leave this page.
Are you sure you want to continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@ -718,4 +797,4 @@ export default function CompanyManagement() {
</AlertDialog>
</div>
);
}
}

View File

@ -1,12 +1,22 @@
"use client";
import { useEffect, useState } from "react";
import {
Activity,
BarChart3,
Building2,
Check,
Copy,
Database,
Plus,
Search,
Settings,
Users,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@ -16,19 +26,10 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Building2,
Users,
Database,
Activity,
Plus,
Settings,
BarChart3,
Search
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ThemeToggle } from "@/components/ui/theme-toggle";
import { useToast } from "@/hooks/use-toast";
import { Copy, Check } from "lucide-react";
interface Company {
id: string;
@ -50,17 +51,29 @@ interface DashboardData {
};
}
interface PlatformSession {
user: {
id: string;
email: string;
name?: string;
isPlatformUser: boolean;
platformRole: string;
};
}
// Custom hook for platform session
function usePlatformSession() {
const [session, setSession] = useState<any>(null);
const [status, setStatus] = useState<"loading" | "authenticated" | "unauthenticated">("loading");
const [session, setSession] = useState<PlatformSession | null>(null);
const [status, setStatus] = useState<
"loading" | "authenticated" | "unauthenticated"
>("loading");
useEffect(() => {
const fetchSession = async () => {
try {
const response = await fetch("/api/platform/auth/session");
const sessionData = await response.json();
if (sessionData?.user?.isPlatformUser) {
setSession(sessionData);
setStatus("authenticated");
@ -85,7 +98,9 @@ export default function PlatformDashboard() {
const { data: session, status } = usePlatformSession();
const router = useRouter();
const { toast } = useToast();
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [dashboardData, setDashboardData] = useState<DashboardData | null>(
null
);
const [isLoading, setIsLoading] = useState(true);
const [showAddCompany, setShowAddCompany] = useState(false);
const [isCreating, setIsCreating] = useState(false);
@ -112,12 +127,12 @@ export default function PlatformDashboard() {
}
fetchDashboardData();
}, [session, status, router]);
}, [session, status, router, fetchDashboardData]);
const copyToClipboard = async (text: string, type: 'email' | 'password') => {
const copyToClipboard = async (text: string, type: "email" | "password") => {
try {
await navigator.clipboard.writeText(text);
if (type === 'email') {
if (type === "email") {
setCopiedEmail(true);
setTimeout(() => setCopiedEmail(false), 2000);
} else {
@ -125,14 +140,14 @@ export default function PlatformDashboard() {
setTimeout(() => setCopiedPassword(false), 2000);
}
} catch (err) {
console.error('Failed to copy: ', err);
console.error("Failed to copy: ", err);
}
};
const getFilteredCompanies = () => {
if (!dashboardData?.companies) return [];
return dashboardData.companies.filter(company =>
return dashboardData.companies.filter((company) =>
company.name.toLowerCase().includes(searchTerm.toLowerCase())
);
};
@ -152,7 +167,12 @@ export default function PlatformDashboard() {
};
const handleCreateCompany = async () => {
if (!newCompanyData.name || !newCompanyData.csvUrl || !newCompanyData.adminEmail || !newCompanyData.adminName) {
if (
!newCompanyData.name ||
!newCompanyData.csvUrl ||
!newCompanyData.adminEmail ||
!newCompanyData.adminName
) {
toast({
title: "Error",
description: "Please fill in all required fields",
@ -172,7 +192,7 @@ export default function PlatformDashboard() {
if (response.ok) {
const result = await response.json();
setShowAddCompany(false);
const companyName = newCompanyData.name;
setNewCompanyData({
name: "",
@ -184,43 +204,65 @@ export default function PlatformDashboard() {
adminPassword: "",
maxUsers: 10,
});
fetchDashboardData(); // Refresh the list
// Show success message with copyable credentials
if (result.generatedPassword) {
toast({
title: "Company Created Successfully!",
description: (
<div className="space-y-3">
<p className="font-medium">Company "{companyName}" has been created.</p>
<p className="font-medium">
Company "{companyName}" has been created.
</p>
<div className="space-y-2">
<div className="flex items-center justify-between bg-muted p-2 rounded">
<div className="flex-1">
<p className="text-xs text-muted-foreground">Admin Email:</p>
<p className="font-mono text-sm">{result.adminUser.email}</p>
<p className="text-xs text-muted-foreground">
Admin Email:
</p>
<p className="font-mono text-sm">
{result.adminUser.email}
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(result.adminUser.email, 'email')}
onClick={() =>
copyToClipboard(result.adminUser.email, "email")
}
className="h-8 w-8 p-0"
>
{copiedEmail ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copiedEmail ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<div className="flex items-center justify-between bg-muted p-2 rounded">
<div className="flex-1">
<p className="text-xs text-muted-foreground">Admin Password:</p>
<p className="font-mono text-sm">{result.generatedPassword}</p>
<p className="text-xs text-muted-foreground">
Admin Password:
</p>
<p className="font-mono text-sm">
{result.generatedPassword}
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(result.generatedPassword, 'password')}
onClick={() =>
copyToClipboard(result.generatedPassword, "password")
}
className="h-8 w-8 p-0"
>
{copiedPassword ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copiedPassword ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
@ -241,7 +283,8 @@ export default function PlatformDashboard() {
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Failed to create company",
description:
error instanceof Error ? error.message : "Failed to create company",
variant: "destructive",
});
} finally {
@ -251,11 +294,16 @@ export default function PlatformDashboard() {
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case "ACTIVE": return "default";
case "TRIAL": return "secondary";
case "SUSPENDED": return "destructive";
case "ARCHIVED": return "outline";
default: return "default";
case "ACTIVE":
return "default";
case "TRIAL":
return "secondary";
case "SUSPENDED":
return "destructive";
case "ARCHIVED":
return "outline";
default:
return "default";
}
};
@ -273,8 +321,16 @@ export default function PlatformDashboard() {
const filteredCompanies = getFilteredCompanies();
const totalCompanies = dashboardData?.pagination?.total || 0;
const totalUsers = dashboardData?.companies?.reduce((sum, company) => sum + company._count.users, 0) || 0;
const totalSessions = dashboardData?.companies?.reduce((sum, company) => sum + company._count.sessions, 0) || 0;
const totalUsers =
dashboardData?.companies?.reduce(
(sum, company) => sum + company._count.users,
0
) || 0;
const totalSessions =
dashboardData?.companies?.reduce(
(sum, company) => sum + company._count.sessions,
0
) || 0;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
@ -291,7 +347,7 @@ export default function PlatformDashboard() {
</div>
<div className="flex gap-4 items-center">
<ThemeToggle />
{/* Search Filter */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
@ -316,7 +372,9 @@ export default function PlatformDashboard() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Companies</CardTitle>
<CardTitle className="text-sm font-medium">
Total Companies
</CardTitle>
<Building2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
@ -336,7 +394,9 @@ export default function PlatformDashboard() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Sessions</CardTitle>
<CardTitle className="text-sm font-medium">
Total Sessions
</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
@ -346,12 +406,15 @@ export default function PlatformDashboard() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Companies</CardTitle>
<CardTitle className="text-sm font-medium">
Active Companies
</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{dashboardData?.companies?.filter(c => c.status === "ACTIVE").length || 0}
{dashboardData?.companies?.filter((c) => c.status === "ACTIVE")
.length || 0}
</div>
</CardContent>
</Card>
@ -396,7 +459,12 @@ export default function PlatformDashboard() {
<Input
id="companyName"
value={newCompanyData.name}
onChange={(e) => setNewCompanyData(prev => ({ ...prev, name: e.target.value }))}
onChange={(e) =>
setNewCompanyData((prev) => ({
...prev,
name: e.target.value,
}))
}
placeholder="Acme Corporation"
/>
</div>
@ -405,7 +473,12 @@ export default function PlatformDashboard() {
<Input
id="csvUrl"
value={newCompanyData.csvUrl}
onChange={(e) => setNewCompanyData(prev => ({ ...prev, csvUrl: e.target.value }))}
onChange={(e) =>
setNewCompanyData((prev) => ({
...prev,
csvUrl: e.target.value,
}))
}
placeholder="https://api.company.com/sessions.csv"
/>
</div>
@ -414,7 +487,12 @@ export default function PlatformDashboard() {
<Input
id="csvUsername"
value={newCompanyData.csvUsername}
onChange={(e) => setNewCompanyData(prev => ({ ...prev, csvUsername: e.target.value }))}
onChange={(e) =>
setNewCompanyData((prev) => ({
...prev,
csvUsername: e.target.value,
}))
}
placeholder="Optional HTTP auth username"
/>
</div>
@ -424,7 +502,12 @@ export default function PlatformDashboard() {
id="csvPassword"
type="password"
value={newCompanyData.csvPassword}
onChange={(e) => setNewCompanyData(prev => ({ ...prev, csvPassword: e.target.value }))}
onChange={(e) =>
setNewCompanyData((prev) => ({
...prev,
csvPassword: e.target.value,
}))
}
placeholder="Optional HTTP auth password"
/>
</div>
@ -433,7 +516,12 @@ export default function PlatformDashboard() {
<Input
id="adminName"
value={newCompanyData.adminName}
onChange={(e) => setNewCompanyData(prev => ({ ...prev, adminName: e.target.value }))}
onChange={(e) =>
setNewCompanyData((prev) => ({
...prev,
adminName: e.target.value,
}))
}
placeholder="John Doe"
/>
</div>
@ -443,7 +531,12 @@ export default function PlatformDashboard() {
id="adminEmail"
type="email"
value={newCompanyData.adminEmail}
onChange={(e) => setNewCompanyData(prev => ({ ...prev, adminEmail: e.target.value }))}
onChange={(e) =>
setNewCompanyData((prev) => ({
...prev,
adminEmail: e.target.value,
}))
}
placeholder="admin@acme.com"
/>
</div>
@ -453,7 +546,12 @@ export default function PlatformDashboard() {
id="adminPassword"
type="password"
value={newCompanyData.adminPassword}
onChange={(e) => setNewCompanyData(prev => ({ ...prev, adminPassword: e.target.value }))}
onChange={(e) =>
setNewCompanyData((prev) => ({
...prev,
adminPassword: e.target.value,
}))
}
placeholder="Leave empty to auto-generate"
/>
</div>
@ -463,17 +561,28 @@ export default function PlatformDashboard() {
id="maxUsers"
type="number"
value={newCompanyData.maxUsers}
onChange={(e) => setNewCompanyData(prev => ({ ...prev, maxUsers: parseInt(e.target.value) || 10 }))}
onChange={(e) =>
setNewCompanyData((prev) => ({
...prev,
maxUsers: parseInt(e.target.value) || 10,
}))
}
min="1"
max="1000"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddCompany(false)}>
<Button
variant="outline"
onClick={() => setShowAddCompany(false)}
>
Cancel
</Button>
<Button onClick={handleCreateCompany} disabled={isCreating}>
<Button
onClick={handleCreateCompany}
disabled={isCreating}
>
{isCreating ? "Creating..." : "Create Company"}
</Button>
</DialogFooter>
@ -500,7 +609,10 @@ export default function PlatformDashboard() {
<span>{company._count.users} users</span>
<span>{company._count.sessions} sessions</span>
<span>{company._count.imports} imports</span>
<span>Created {new Date(company.createdAt).toLocaleDateString()}</span>
<span>
Created{" "}
{new Date(company.createdAt).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex gap-2">
@ -508,10 +620,12 @@ export default function PlatformDashboard() {
<BarChart3 className="w-4 h-4 mr-2" />
Analytics
</Button>
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/platform/companies/${company.id}`)}
onClick={() =>
router.push(`/platform/companies/${company.id}`)
}
>
<Settings className="w-4 h-4 mr-2" />
Manage
@ -525,7 +639,11 @@ export default function PlatformDashboard() {
{searchTerm ? (
<div className="space-y-2">
<p>No companies match "{searchTerm}".</p>
<Button variant="link" onClick={() => setSearchTerm("")} className="text-sm">
<Button
variant="link"
onClick={() => setSearchTerm("")}
className="text-sm"
>
Clear search to see all companies
</Button>
</div>
@ -540,4 +658,4 @@ export default function PlatformDashboard() {
</div>
</div>
);
}
}

View File

@ -1,8 +1,8 @@
"use client";
import { SessionProvider } from "next-auth/react";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/toaster";
export default function PlatformLayout({
children,
@ -22,4 +22,4 @@ export default function PlatformLayout({
</SessionProvider>
</ThemeProvider>
);
}
}

View File

@ -1,16 +1,18 @@
"use client";
import { useState } from "react";
import { signIn, getSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { useId, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ThemeToggle } from "@/components/ui/theme-toggle";
export default function PlatformLoginPage() {
const emailId = useId();
const passwordId = useId();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
@ -36,7 +38,7 @@ export default function PlatformLoginPage() {
// Login successful, redirect to dashboard
router.push("/platform/dashboard");
}
} catch (error) {
} catch (_error) {
setError("An error occurred during login");
} finally {
setIsLoading(false);
@ -64,9 +66,9 @@ export default function PlatformLoginPage() {
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor={emailId}>Email</Label>
<Input
id="email"
id={emailId}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
@ -77,9 +79,9 @@ export default function PlatformLoginPage() {
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor={passwordId}>Password</Label>
<Input
id="password"
id={passwordId}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
@ -89,11 +91,7 @@ export default function PlatformLoginPage() {
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign In"}
</Button>
</form>
@ -101,4 +99,4 @@ export default function PlatformLoginPage() {
</Card>
</div>
);
}
}

View File

@ -1,7 +1,7 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function PlatformIndexPage() {
const router = useRouter();
@ -14,8 +14,10 @@ export default function PlatformIndexPage() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">Redirecting to platform dashboard...</p>
<p className="text-muted-foreground">
Redirecting to platform dashboard...
</p>
</div>
</div>
);
}
}

View File

@ -1,7 +1,7 @@
"use client";
import { SessionProvider } from "next-auth/react";
import { ReactNode } from "react";
import type { ReactNode } from "react";
import { ThemeProvider } from "@/components/theme-provider";
export function Providers({ children }: { children: ReactNode }) {

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function RegisterPage() {
const [email, setEmail] = useState<string>("");

View File

@ -1,6 +1,6 @@
"use client";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
// Component that uses useSearchParams wrapped in Suspense
function ResetPasswordForm() {