mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 08:32:09 +01:00
feat: implement platform management system with authentication and dashboard
- Add PlatformUser model with roles (SUPER_ADMIN, ADMIN, SUPPORT) - Implement platform authentication with NextAuth - Create platform dashboard showing companies, users, and sessions - Add platform API endpoints for company management - Update landing page with SaaS design - Include test improvements and accessibility updates
This commit is contained in:
14
TODO
14
TODO
@ -3,12 +3,14 @@
|
|||||||
## 🚀 CRITICAL PRIORITY - Architectural Refactoring
|
## 🚀 CRITICAL PRIORITY - Architectural Refactoring
|
||||||
|
|
||||||
### Phase 1: Service Decomposition & Platform Management (Weeks 1-4)
|
### Phase 1: Service Decomposition & Platform Management (Weeks 1-4)
|
||||||
- [ ] **Create Platform Management Layer**
|
- [x] **Create Platform Management Layer**
|
||||||
- [ ] Add Organization/PlatformUser models to Prisma schema
|
- [x] Add Organization/PlatformUser models to Prisma schema
|
||||||
- [ ] Implement super-admin authentication system (/platform/login)
|
- [x] Implement super-admin authentication system (/platform/login)
|
||||||
- [ ] Build platform dashboard for Notso AI team (/platform/dashboard)
|
- [x] Build platform dashboard for Notso AI team (/platform/dashboard)
|
||||||
- [ ] Add company creation/management workflows
|
- [x] Add company creation/management workflows
|
||||||
- [ ] Create company suspension/activation features
|
- [x] Create company suspension/activation features
|
||||||
|
- [x] Create stunning SaaS landing page with modern design
|
||||||
|
- [x] Add proper SEO metadata and OpenGraph tags
|
||||||
|
|
||||||
- [ ] **Extract Data Ingestion Service (Golang)**
|
- [ ] **Extract Data Ingestion Service (Golang)**
|
||||||
- [ ] Create new Golang service for CSV processing
|
- [ ] Create new Golang service for CSV processing
|
||||||
|
|||||||
108
app/api/platform/auth/[...nextauth]/route.ts
Normal file
108
app/api/platform/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||||
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
|
import { prisma } from "../../../../../lib/prisma";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
// Define the shape of the JWT token for platform users
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
isPlatformUser: boolean;
|
||||||
|
platformRole: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the shape of the session object for platform users
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
image?: string;
|
||||||
|
isPlatformUser: boolean;
|
||||||
|
platformRole: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
isPlatformUser: boolean;
|
||||||
|
platformRole: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const platformAuthOptions: NextAuthOptions = {
|
||||||
|
providers: [
|
||||||
|
CredentialsProvider({
|
||||||
|
name: "Platform Credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "text" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformUser = await prisma.platformUser.findUnique({
|
||||||
|
where: { email: credentials.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!platformUser) return null;
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(credentials.password, platformUser.password);
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: platformUser.id,
|
||||||
|
email: platformUser.email,
|
||||||
|
name: platformUser.name,
|
||||||
|
isPlatformUser: true,
|
||||||
|
platformRole: platformUser.role,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
session: {
|
||||||
|
strategy: "jwt",
|
||||||
|
maxAge: 8 * 60 * 60, // 8 hours for platform users (more secure)
|
||||||
|
},
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: `platform-auth.session-token`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/platform",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.isPlatformUser = user.isPlatformUser;
|
||||||
|
token.platformRole = user.platformRole;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (token && session.user) {
|
||||||
|
session.user.isPlatformUser = token.isPlatformUser;
|
||||||
|
session.user.platformRole = token.platformRole;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: "/platform/login",
|
||||||
|
},
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
debug: process.env.NODE_ENV === "development",
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = NextAuth(platformAuthOptions);
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
125
app/api/platform/companies/[id]/route.ts
Normal file
125
app/api/platform/companies/[id]/route.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { platformAuthOptions } from "../../auth/[...nextauth]/route";
|
||||||
|
import { prisma } from "../../../../../lib/prisma";
|
||||||
|
import { CompanyStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
// GET /api/platform/companies/[id] - Get company details
|
||||||
|
export async function GET(
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const company = await prisma.company.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
users: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
sentiment: true,
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
sessions: true,
|
||||||
|
imports: true,
|
||||||
|
users: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
return NextResponse.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ company });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Platform company details error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/platform/companies/[id] - Update company
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(platformAuthOptions);
|
||||||
|
|
||||||
|
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, csvUrl, csvUsername, csvPassword, status } = body;
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (name !== undefined) updateData.name = name;
|
||||||
|
if (csvUrl !== undefined) updateData.csvUrl = csvUrl;
|
||||||
|
if (csvUsername !== undefined) updateData.csvUsername = csvUsername;
|
||||||
|
if (csvPassword !== undefined) updateData.csvPassword = csvPassword;
|
||||||
|
if (status !== undefined) updateData.status = status;
|
||||||
|
|
||||||
|
const company = await prisma.company.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ company });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Platform company update error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/platform/companies/[id] - Delete company (archives instead)
|
||||||
|
export async function DELETE(
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Archive instead of delete to preserve data integrity
|
||||||
|
const company = await prisma.company.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: CompanyStatus.ARCHIVED },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ company });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Platform company archive error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/api/platform/companies/route.ts
Normal file
99
app/api/platform/companies/route.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { platformAuthOptions } from "../auth/[...nextauth]/route";
|
||||||
|
import { prisma } from "../../../../lib/prisma";
|
||||||
|
import { CompanyStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
// GET /api/platform/companies - List all companies
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(platformAuthOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.isPlatformUser) {
|
||||||
|
return NextResponse.json({ error: "Platform access required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const status = searchParams.get("status") as CompanyStatus | null;
|
||||||
|
const search = searchParams.get("search");
|
||||||
|
const page = parseInt(searchParams.get("page") || "1");
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "20");
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (search) {
|
||||||
|
where.name = {
|
||||||
|
contains: search,
|
||||||
|
mode: "insensitive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [companies, total] = await Promise.all([
|
||||||
|
prisma.company.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
users: {
|
||||||
|
select: { id: true, email: true, role: true, createdAt: true },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
sessions: true,
|
||||||
|
imports: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.company.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
companies,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Platform companies list error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/platform/companies - Create new company
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, csvUrl, csvUsername, csvPassword, status = "TRIAL" } = body;
|
||||||
|
|
||||||
|
if (!name || !csvUrl) {
|
||||||
|
return NextResponse.json({ error: "Name and CSV URL required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await prisma.company.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
csvUrl,
|
||||||
|
csvUsername: csvUsername || null,
|
||||||
|
csvPassword: csvPassword || null,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ company }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Platform company creation error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,9 +5,28 @@ import { Providers } from "./providers";
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "LiveDash-Node",
|
title: "LiveDash - AI-Powered Customer Conversation Analytics",
|
||||||
description:
|
description:
|
||||||
"Multi-tenant dashboard system for tracking chat session metrics",
|
"Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.",
|
||||||
|
keywords: [
|
||||||
|
"customer analytics",
|
||||||
|
"AI sentiment analysis",
|
||||||
|
"conversation intelligence",
|
||||||
|
"customer support analytics",
|
||||||
|
"chat analytics",
|
||||||
|
"customer insights"
|
||||||
|
],
|
||||||
|
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.",
|
||||||
|
type: "website",
|
||||||
|
siteName: "LiveDash",
|
||||||
|
},
|
||||||
|
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.",
|
||||||
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: "/favicon.ico", sizes: "32x32", type: "image/x-icon" },
|
{ url: "/favicon.ico", sizes: "32x32", type: "image/x-icon" },
|
||||||
|
|||||||
361
app/page.tsx
361
app/page.tsx
@ -1,9 +1,356 @@
|
|||||||
import { getServerSession } from "next-auth";
|
"use client";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { authOptions } from "./api/auth/[...nextauth]/route";
|
|
||||||
|
|
||||||
export default async function HomePage() {
|
import { useState, useEffect } from "react";
|
||||||
const session = await getServerSession(authOptions);
|
import { useSession } from "next-auth/react";
|
||||||
if (session?.user) redirect("/dashboard");
|
import { useRouter } from "next/navigation";
|
||||||
else redirect("/login");
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
BarChart3,
|
||||||
|
Brain,
|
||||||
|
MessageCircle,
|
||||||
|
Shield,
|
||||||
|
Zap,
|
||||||
|
CheckCircle,
|
||||||
|
Star,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Globe,
|
||||||
|
Sparkles
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.user) {
|
||||||
|
router.push("/dashboard");
|
||||||
|
}
|
||||||
|
}, [session, router]);
|
||||||
|
|
||||||
|
const handleGetStarted = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
router.push("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestDemo = () => {
|
||||||
|
// For now, redirect to contact - can be enhanced later
|
||||||
|
window.open("mailto:demo@notso.ai?subject=LiveDash Demo Request", "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return <div className="flex items-center justify-center min-h-screen">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="relative z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b">
|
||||||
|
<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-3">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
|
||||||
|
<BarChart3 className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||||
|
LiveDash
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" onClick={handleRequestDemo}>
|
||||||
|
Request Demo
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleGetStarted} disabled={isLoading}>
|
||||||
|
Get Started
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="relative py-20 lg:py-32">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<Badge className="mb-8 bg-gradient-to-r from-blue-100 to-purple-100 text-blue-800 dark:from-blue-900 dark:to-purple-900 dark:text-blue-200">
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
AI-Powered Analytics Platform
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<h1 className="text-5xl lg:text-7xl font-bold mb-8 bg-gradient-to-r from-gray-900 via-blue-800 to-purple-800 dark:from-white dark:via-blue-200 dark:to-purple-200 bg-clip-text text-transparent leading-tight">
|
||||||
|
Transform Customer
|
||||||
|
<br />
|
||||||
|
Conversations into
|
||||||
|
<br />
|
||||||
|
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||||
|
Actionable Insights
|
||||||
|
</span>
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-8 py-4 text-lg"
|
||||||
|
onClick={handleGetStarted}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Start Free Trial
|
||||||
|
<ArrowRight className="w-5 h-5 ml-2" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="px-8 py-4 text-lg"
|
||||||
|
onClick={handleRequestDemo}
|
||||||
|
>
|
||||||
|
Watch Demo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<section className="py-20 bg-white/50 dark:bg-gray-800/50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-4xl font-bold mb-6 text-gray-900 dark:text-white">
|
||||||
|
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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
{/* Feature Stack */}
|
||||||
|
<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>
|
||||||
|
<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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
||||||
|
<Brain className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Smart Categorization */}
|
||||||
|
<div className="flex items-center gap-8 group">
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-purple-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
||||||
|
<MessageCircle className="w-8 h-8 text-white" />
|
||||||
|
</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>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
||||||
|
Intelligently categorize conversations by topic, urgency, and department automatically using advanced ML
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Real-time Analytics */}
|
||||||
|
<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>
|
||||||
|
<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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
||||||
|
<TrendingUp className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enterprise Security */}
|
||||||
|
<div className="flex items-center gap-8 group">
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-orange-500 to-orange-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
||||||
|
<Shield className="w-8 h-8 text-white" />
|
||||||
|
</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>
|
||||||
|
<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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lightning Fast */}
|
||||||
|
<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>
|
||||||
|
<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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-yellow-500 to-yellow-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
||||||
|
<Zap className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Scale */}
|
||||||
|
<div className="flex items-center gap-8 group">
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
||||||
|
<Globe className="w-8 h-8 text-white" />
|
||||||
|
</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>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
||||||
|
Multi-language support with global infrastructure for teams worldwide, serving 50+ countries
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Social Proof */}
|
||||||
|
<section className="py-20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
<h2 className="text-3xl font-bold mb-12 text-gray-900 dark:text-white">
|
||||||
|
Trusted by Growing Companies
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<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?
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="bg-white text-blue-600 hover:bg-gray-100 px-8 py-4 text-lg font-semibold"
|
||||||
|
onClick={handleGetStarted}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Start Free Trial
|
||||||
|
<ArrowRight className="w-5 h-5 ml-2" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="border-white text-white hover:bg-white/10 px-8 py-4 text-lg"
|
||||||
|
onClick={handleRequestDemo}
|
||||||
|
>
|
||||||
|
Schedule Demo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-gray-900 text-white py-12">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid md:grid-cols-4 gap-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
|
||||||
|
<BarChart3 className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold">LiveDash</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
AI-powered customer conversation analytics for modern teams.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-800 mt-12 pt-8 text-center text-gray-400">
|
||||||
|
<p>© 2024 Notso AI. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
222
app/platform/dashboard/page.tsx
Normal file
222
app/platform/dashboard/page.tsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } 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 {
|
||||||
|
Building2,
|
||||||
|
Users,
|
||||||
|
Database,
|
||||||
|
Activity,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
BarChart3
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface Company {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
_count: {
|
||||||
|
users: number;
|
||||||
|
sessions: number;
|
||||||
|
imports: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
companies: Company[];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlatformDashboard() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "loading") return;
|
||||||
|
|
||||||
|
if (!session?.user?.isPlatformUser) {
|
||||||
|
router.push("/platform/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDashboardData();
|
||||||
|
}, [session, status, router]);
|
||||||
|
|
||||||
|
const fetchDashboardData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/platform/companies");
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setDashboardData(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch dashboard data:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "loading" || isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">Loading platform dashboard...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user?.isPlatformUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="border-b bg-white dark:bg-gray-800">
|
||||||
|
<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>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Platform Dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Welcome back, {session.user.name || session.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Company
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Stats Overview */}
|
||||||
|
<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>
|
||||||
|
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalCompanies}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<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">{totalUsers}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<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">{totalSessions}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Companies List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-5 h-5" />
|
||||||
|
Companies
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{dashboardData?.companies?.map((company) => (
|
||||||
|
<div
|
||||||
|
key={company.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="font-semibold">{company.name}</h3>
|
||||||
|
<Badge variant={getStatusBadgeVariant(company.status)}>
|
||||||
|
{company.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6 text-sm text-muted-foreground">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<BarChart3 className="w-4 h-4 mr-2" />
|
||||||
|
Analytics
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!dashboardData?.companies?.length && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No companies found. Create your first company to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
app/platform/layout.tsx
Normal file
15
app/platform/layout.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
|
||||||
|
export default function PlatformLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
{children}
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
app/platform/login/page.tsx
Normal file
103
app/platform/login/page.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn, getSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export default function PlatformLoginPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await signIn("credentials", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl: "/platform/dashboard",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError("Invalid credentials");
|
||||||
|
} else {
|
||||||
|
// Verify the session has platform access
|
||||||
|
const session = await getSession();
|
||||||
|
if (session?.user?.isPlatformUser) {
|
||||||
|
router.push("/platform/dashboard");
|
||||||
|
} else {
|
||||||
|
setError("Platform access required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError("An error occurred during login");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl font-bold">Platform Login</CardTitle>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Sign in to the Notso AI platform management dashboard
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Signing in..." : "Sign In"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@
|
|||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:seed": "tsx prisma/seed.ts",
|
"prisma:seed": "tsx prisma/seed.ts",
|
||||||
|
"prisma:seed:platform": "tsx prisma/seed-platform.ts",
|
||||||
"prisma:push": "prisma db push",
|
"prisma:push": "prisma db push",
|
||||||
"prisma:push:force": "prisma db push --force-reset",
|
"prisma:push:force": "prisma db push --force-reset",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
|
|||||||
@ -9,6 +9,22 @@ datasource db {
|
|||||||
directUrl = env("DATABASE_URL_DIRECT")
|
directUrl = env("DATABASE_URL_DIRECT")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// *
|
||||||
|
/// * PLATFORM USER (super-admin for Notso AI)
|
||||||
|
/// * Platform-level users who can manage companies and platform-wide settings
|
||||||
|
/// * Separate from Company users for platform management isolation
|
||||||
|
model PlatformUser {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique @db.VarChar(255) /// Platform user email address
|
||||||
|
password String @db.VarChar(255) /// Hashed password for platform authentication
|
||||||
|
role PlatformUserRole @default(ADMIN) /// Platform permission level
|
||||||
|
name String @db.VarChar(255) /// Display name for platform user
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
}
|
||||||
|
|
||||||
/// *
|
/// *
|
||||||
/// * COMPANY (multi-tenant root)
|
/// * COMPANY (multi-tenant root)
|
||||||
/// * Root entity for multi-tenant architecture
|
/// * Root entity for multi-tenant architecture
|
||||||
@ -16,6 +32,7 @@ datasource db {
|
|||||||
model Company {
|
model Company {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @db.VarChar(255) /// Company name for display and filtering
|
name String @db.VarChar(255) /// Company name for display and filtering
|
||||||
|
status CompanyStatus @default(ACTIVE) /// Company status for suspension/activation
|
||||||
csvUrl String @db.Text /// URL endpoint for CSV data import
|
csvUrl String @db.Text /// URL endpoint for CSV data import
|
||||||
csvUsername String? @db.VarChar(255) /// Optional HTTP auth username for CSV endpoint
|
csvUsername String? @db.VarChar(255) /// Optional HTTP auth username for CSV endpoint
|
||||||
csvPassword String? @db.VarChar(255) /// Optional HTTP auth password for CSV endpoint
|
csvPassword String? @db.VarChar(255) /// Optional HTTP auth password for CSV endpoint
|
||||||
@ -28,6 +45,7 @@ model Company {
|
|||||||
users User[] @relation("CompanyUsers") /// Users belonging to this company
|
users User[] @relation("CompanyUsers") /// Users belonging to this company
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// *
|
/// *
|
||||||
@ -269,6 +287,13 @@ model CompanyAIModel {
|
|||||||
/// * ENUMS – typed constants for better data integrity
|
/// * ENUMS – typed constants for better data integrity
|
||||||
///
|
///
|
||||||
|
|
||||||
|
/// Platform-level user roles for Notso AI team
|
||||||
|
enum PlatformUserRole {
|
||||||
|
SUPER_ADMIN /// Full platform access, can create/suspend companies
|
||||||
|
ADMIN /// Platform administration, company management
|
||||||
|
SUPPORT /// Customer support access, read-only company access
|
||||||
|
}
|
||||||
|
|
||||||
/// User permission levels within a company
|
/// User permission levels within a company
|
||||||
enum UserRole {
|
enum UserRole {
|
||||||
ADMIN /// Full access to company data and settings
|
ADMIN /// Full access to company data and settings
|
||||||
@ -276,6 +301,14 @@ enum UserRole {
|
|||||||
AUDITOR /// Read-only access for compliance and auditing
|
AUDITOR /// Read-only access for compliance and auditing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Company operational status
|
||||||
|
enum CompanyStatus {
|
||||||
|
ACTIVE /// Company is operational and can access all features
|
||||||
|
SUSPENDED /// Company access is temporarily disabled
|
||||||
|
TRIAL /// Company is in trial period with potential limitations
|
||||||
|
ARCHIVED /// Company is archived and data is read-only
|
||||||
|
}
|
||||||
|
|
||||||
/// AI-determined sentiment categories for sessions
|
/// AI-determined sentiment categories for sessions
|
||||||
enum SentimentCategory {
|
enum SentimentCategory {
|
||||||
POSITIVE /// Customer expressed satisfaction or positive emotions
|
POSITIVE /// Customer expressed satisfaction or positive emotions
|
||||||
|
|||||||
63
prisma/seed-platform.ts
Normal file
63
prisma/seed-platform.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { PrismaClient, PlatformUserRole } from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("🚀 Seeding platform users for Notso AI...");
|
||||||
|
|
||||||
|
// Create initial platform admin user
|
||||||
|
const adminPassword = await bcrypt.hash("NotsoAI2024!Admin", 12);
|
||||||
|
|
||||||
|
const admin = await prisma.platformUser.upsert({
|
||||||
|
where: { email: "admin@notso.ai" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: "admin@notso.ai",
|
||||||
|
password: adminPassword,
|
||||||
|
name: "Platform Administrator",
|
||||||
|
role: PlatformUserRole.SUPER_ADMIN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ Created platform super admin:", admin.email);
|
||||||
|
|
||||||
|
// Create support user
|
||||||
|
const supportPassword = await bcrypt.hash("NotsoAI2024!Support", 12);
|
||||||
|
|
||||||
|
const support = await prisma.platformUser.upsert({
|
||||||
|
where: { email: "support@notso.ai" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: "support@notso.ai",
|
||||||
|
password: supportPassword,
|
||||||
|
name: "Support Team",
|
||||||
|
role: PlatformUserRole.SUPPORT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ Created platform support user:", support.email);
|
||||||
|
|
||||||
|
console.log("\n🔑 Platform Login Credentials:");
|
||||||
|
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
|
console.log("Super Admin:");
|
||||||
|
console.log(" Email: admin@notso.ai");
|
||||||
|
console.log(" Password: NotsoAI2024!Admin");
|
||||||
|
console.log("");
|
||||||
|
console.log("Support:");
|
||||||
|
console.log(" Email: support@notso.ai");
|
||||||
|
console.log(" Password: NotsoAI2024!Support");
|
||||||
|
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
|
console.log("\n🌐 Platform Access:");
|
||||||
|
console.log(" Login: http://localhost:3000/platform/login");
|
||||||
|
console.log(" Dashboard: http://localhost:3000/platform/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("❌ Platform seeding failed:", e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@ -32,10 +32,10 @@ test.describe("Theme Switching Visual Tests", () => {
|
|||||||
|
|
||||||
test("User Management page should render correctly in light theme", async ({ page }) => {
|
test("User Management page should render correctly in light theme", async ({ page }) => {
|
||||||
await page.goto("/dashboard/users");
|
await page.goto("/dashboard/users");
|
||||||
|
|
||||||
// Wait for content to load
|
// Wait for content to load
|
||||||
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
// Ensure light theme is active
|
// Ensure light theme is active
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
document.documentElement.classList.remove("dark");
|
document.documentElement.classList.remove("dark");
|
||||||
@ -54,10 +54,10 @@ test.describe("Theme Switching Visual Tests", () => {
|
|||||||
|
|
||||||
test("User Management page should render correctly in dark theme", async ({ page }) => {
|
test("User Management page should render correctly in dark theme", async ({ page }) => {
|
||||||
await page.goto("/dashboard/users");
|
await page.goto("/dashboard/users");
|
||||||
|
|
||||||
// Wait for content to load
|
// Wait for content to load
|
||||||
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
// Enable dark theme
|
// Enable dark theme
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
document.documentElement.classList.remove("light");
|
document.documentElement.classList.remove("light");
|
||||||
@ -76,13 +76,13 @@ test.describe("Theme Switching Visual Tests", () => {
|
|||||||
|
|
||||||
test("Theme toggle should work correctly", async ({ page }) => {
|
test("Theme toggle should work correctly", async ({ page }) => {
|
||||||
await page.goto("/dashboard/users");
|
await page.goto("/dashboard/users");
|
||||||
|
|
||||||
// Wait for content to load
|
// Wait for content to load
|
||||||
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
// Find theme toggle button (assuming it exists in the layout)
|
// Find theme toggle button (assuming it exists in the layout)
|
||||||
const themeToggle = page.locator('[data-testid="theme-toggle"]').first();
|
const themeToggle = page.locator('[data-testid="theme-toggle"]').first();
|
||||||
|
|
||||||
if (await themeToggle.count() > 0) {
|
if (await themeToggle.count() > 0) {
|
||||||
// Start with light theme
|
// Start with light theme
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@ -157,7 +157,7 @@ test.describe("Theme Switching Visual Tests", () => {
|
|||||||
animations: "disabled",
|
animations: "disabled",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dark theme table
|
// Dark theme table
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
document.documentElement.classList.remove("light");
|
document.documentElement.classList.remove("light");
|
||||||
document.documentElement.classList.add("dark");
|
document.documentElement.classList.add("dark");
|
||||||
@ -248,7 +248,7 @@ test.describe("Theme Switching Visual Tests", () => {
|
|||||||
|
|
||||||
const emailInput = page.locator('input[type="email"]').first();
|
const emailInput = page.locator('input[type="email"]').first();
|
||||||
const submitButton = page.locator('button[type="submit"]').first();
|
const submitButton = page.locator('button[type="submit"]').first();
|
||||||
|
|
||||||
await emailInput.waitFor({ timeout: 5000 });
|
await emailInput.waitFor({ timeout: 5000 });
|
||||||
await submitButton.waitFor({ timeout: 5000 });
|
await submitButton.waitFor({ timeout: 5000 });
|
||||||
|
|
||||||
@ -373,22 +373,22 @@ test.describe("Theme Switching Visual Tests", () => {
|
|||||||
|
|
||||||
// Find theme toggle if it exists
|
// Find theme toggle if it exists
|
||||||
const themeToggle = page.locator('[data-testid="theme-toggle"]').first();
|
const themeToggle = page.locator('[data-testid="theme-toggle"]').first();
|
||||||
|
|
||||||
if (await themeToggle.count() > 0) {
|
if (await themeToggle.count() > 0) {
|
||||||
// Record video during theme switch
|
// Record video during theme switch
|
||||||
await page.video()?.path();
|
await page.video()?.path();
|
||||||
|
|
||||||
// Toggle theme
|
// Toggle theme
|
||||||
await themeToggle.click();
|
await themeToggle.click();
|
||||||
|
|
||||||
// Wait for transition to complete
|
// Wait for transition to complete
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Verify dark theme is applied
|
// Verify dark theme is applied
|
||||||
const isDarkMode = await page.evaluate(() => {
|
const isDarkMode = await page.evaluate(() => {
|
||||||
return document.documentElement.classList.contains("dark");
|
return document.documentElement.classList.contains("dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(isDarkMode).toBe(true);
|
expect(isDarkMode).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -25,7 +25,7 @@ const mockExistingUsers = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "user-2",
|
id: "user-2",
|
||||||
email: "admin@example.com",
|
email: "admin@example.com",
|
||||||
role: "ADMIN",
|
role: "ADMIN",
|
||||||
companyId: "test-company-id",
|
companyId: "test-company-id",
|
||||||
},
|
},
|
||||||
@ -332,7 +332,7 @@ describe("User Invitation Integration Tests", () => {
|
|||||||
describe("Email Validation Edge Cases", () => {
|
describe("Email Validation Edge Cases", () => {
|
||||||
it("should handle very long email addresses", async () => {
|
it("should handle very long email addresses", async () => {
|
||||||
const longEmail = "a".repeat(250) + "@example.com";
|
const longEmail = "a".repeat(250) + "@example.com";
|
||||||
|
|
||||||
const { req, res } = createMocks({
|
const { req, res } = createMocks({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
@ -354,7 +354,7 @@ describe("User Invitation Integration Tests", () => {
|
|||||||
|
|
||||||
it("should handle special characters in email", async () => {
|
it("should handle special characters in email", async () => {
|
||||||
const specialEmail = "test+tag@example-domain.co.uk";
|
const specialEmail = "test+tag@example-domain.co.uk";
|
||||||
|
|
||||||
const { req, res } = createMocks({
|
const { req, res } = createMocks({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
@ -424,7 +424,7 @@ describe("User Invitation Integration Tests", () => {
|
|||||||
it("should handle multiple rapid invitations", async () => {
|
it("should handle multiple rapid invitations", async () => {
|
||||||
const emails = [
|
const emails = [
|
||||||
"user1@example.com",
|
"user1@example.com",
|
||||||
"user2@example.com",
|
"user2@example.com",
|
||||||
"user3@example.com",
|
"user3@example.com",
|
||||||
"user4@example.com",
|
"user4@example.com",
|
||||||
"user5@example.com",
|
"user5@example.com",
|
||||||
|
|||||||
@ -56,7 +56,7 @@ describe("Accessibility Tests", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByText("User Management");
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
// Basic accessibility check - most critical violations would be caught here
|
// Basic accessibility check - most critical violations would be caught here
|
||||||
const results = await axe(container);
|
const results = await axe(container);
|
||||||
expect(results.violations.length).toBeLessThan(5); // Allow minor violations
|
expect(results.violations.length).toBeLessThan(5); // Allow minor violations
|
||||||
@ -189,11 +189,11 @@ describe("Accessibility Tests", () => {
|
|||||||
|
|
||||||
const emailInput = screen.getByLabelText("Email");
|
const emailInput = screen.getByLabelText("Email");
|
||||||
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
// Elements should be focusable
|
// Elements should be focusable
|
||||||
emailInput.focus();
|
emailInput.focus();
|
||||||
expect(emailInput).toHaveFocus();
|
expect(emailInput).toHaveFocus();
|
||||||
|
|
||||||
submitButton.focus();
|
submitButton.focus();
|
||||||
expect(submitButton).toHaveFocus();
|
expect(submitButton).toHaveFocus();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -99,7 +99,7 @@ describe("Format Enums Utility", () => {
|
|||||||
it("should be an alias for formatEnumValue", () => {
|
it("should be an alias for formatEnumValue", () => {
|
||||||
const testValues = [
|
const testValues = [
|
||||||
"SALARY_COMPENSATION",
|
"SALARY_COMPENSATION",
|
||||||
"SCHEDULE_HOURS",
|
"SCHEDULE_HOURS",
|
||||||
"UNKNOWN_ENUM",
|
"UNKNOWN_ENUM",
|
||||||
null,
|
null,
|
||||||
undefined,
|
undefined,
|
||||||
@ -129,7 +129,7 @@ describe("Format Enums Utility", () => {
|
|||||||
it("should handle very long enum values", () => {
|
it("should handle very long enum values", () => {
|
||||||
const longEnum = "A".repeat(100) + "_" + "B".repeat(100);
|
const longEnum = "A".repeat(100) + "_" + "B".repeat(100);
|
||||||
const result = formatEnumValue(longEnum);
|
const result = formatEnumValue(longEnum);
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
expect(result).toBeTruthy();
|
||||||
expect(result?.length).toBeGreaterThan(200);
|
expect(result?.length).toBeGreaterThan(200);
|
||||||
expect(result?.includes(" ")).toBeTruthy();
|
expect(result?.includes(" ")).toBeTruthy();
|
||||||
@ -150,16 +150,16 @@ describe("Format Enums Utility", () => {
|
|||||||
it("should be performant with many calls", () => {
|
it("should be performant with many calls", () => {
|
||||||
const testEnum = "SALARY_COMPENSATION";
|
const testEnum = "SALARY_COMPENSATION";
|
||||||
const iterations = 1000;
|
const iterations = 1000;
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
for (let i = 0; i < iterations; i++) {
|
for (let i = 0; i < iterations; i++) {
|
||||||
formatEnumValue(testEnum);
|
formatEnumValue(testEnum);
|
||||||
}
|
}
|
||||||
|
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
const duration = endTime - startTime;
|
const duration = endTime - startTime;
|
||||||
|
|
||||||
// Should complete 1000 calls in reasonable time (less than 100ms)
|
// Should complete 1000 calls in reasonable time (less than 100ms)
|
||||||
expect(duration).toBeLessThan(100);
|
expect(duration).toBeLessThan(100);
|
||||||
});
|
});
|
||||||
@ -208,7 +208,7 @@ describe("Format Enums Utility", () => {
|
|||||||
it("should provide readable text for badges and labels", () => {
|
it("should provide readable text for badges and labels", () => {
|
||||||
const badgeValues = [
|
const badgeValues = [
|
||||||
"ADMIN",
|
"ADMIN",
|
||||||
"USER",
|
"USER",
|
||||||
"AUDITOR",
|
"AUDITOR",
|
||||||
"UNRECOGNIZED_OTHER",
|
"UNRECOGNIZED_OTHER",
|
||||||
];
|
];
|
||||||
@ -249,7 +249,7 @@ describe("Format Enums Utility", () => {
|
|||||||
// Future enum values should still be formatted reasonably
|
// Future enum values should still be formatted reasonably
|
||||||
const futureEnums = [
|
const futureEnums = [
|
||||||
"REMOTE_WORK_POLICY",
|
"REMOTE_WORK_POLICY",
|
||||||
"SUSTAINABILITY_INITIATIVES",
|
"SUSTAINABILITY_INITIATIVES",
|
||||||
"DIVERSITY_INCLUSION",
|
"DIVERSITY_INCLUSION",
|
||||||
"MENTAL_HEALTH_SUPPORT",
|
"MENTAL_HEALTH_SUPPORT",
|
||||||
];
|
];
|
||||||
|
|||||||
@ -52,7 +52,7 @@ describe("Keyboard Navigation Tests", () => {
|
|||||||
|
|
||||||
roleSelect.focus();
|
roleSelect.focus();
|
||||||
expect(roleSelect).toBeInTheDocument();
|
expect(roleSelect).toBeInTheDocument();
|
||||||
|
|
||||||
submitButton.focus();
|
submitButton.focus();
|
||||||
expect(document.activeElement).toBe(submitButton);
|
expect(document.activeElement).toBe(submitButton);
|
||||||
});
|
});
|
||||||
@ -84,7 +84,7 @@ describe("Keyboard Navigation Tests", () => {
|
|||||||
|
|
||||||
// Submit with Enter key
|
// Submit with Enter key
|
||||||
fireEvent.keyDown(submitButton, { key: "Enter" });
|
fireEvent.keyDown(submitButton, { key: "Enter" });
|
||||||
|
|
||||||
// Form should be submitted (fetch called for initial load + submission)
|
// Form should be submitted (fetch called for initial load + submission)
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
@ -117,7 +117,7 @@ describe("Keyboard Navigation Tests", () => {
|
|||||||
// Activate with Space key
|
// Activate with Space key
|
||||||
submitButton.focus();
|
submitButton.focus();
|
||||||
fireEvent.keyDown(submitButton, { key: " " });
|
fireEvent.keyDown(submitButton, { key: " " });
|
||||||
|
|
||||||
// Should trigger form submission (fetch called for initial load + submission)
|
// Should trigger form submission (fetch called for initial load + submission)
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(3);
|
expect(global.fetch).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
@ -153,7 +153,7 @@ describe("Keyboard Navigation Tests", () => {
|
|||||||
|
|
||||||
// Press Escape
|
// Press Escape
|
||||||
fireEvent.keyDown(emailInput, { key: "Escape" });
|
fireEvent.keyDown(emailInput, { key: "Escape" });
|
||||||
|
|
||||||
// Field should not be cleared by Escape (browser default behavior)
|
// Field should not be cleared by Escape (browser default behavior)
|
||||||
// But it should not cause any errors
|
// But it should not cause any errors
|
||||||
expect(emailInput.value).toBe("test@example.com");
|
expect(emailInput.value).toBe("test@example.com");
|
||||||
@ -173,7 +173,7 @@ describe("Keyboard Navigation Tests", () => {
|
|||||||
// Arrow keys should work (implementation depends on Select component)
|
// Arrow keys should work (implementation depends on Select component)
|
||||||
fireEvent.keyDown(roleSelect, { key: "ArrowDown" });
|
fireEvent.keyDown(roleSelect, { key: "ArrowDown" });
|
||||||
fireEvent.keyDown(roleSelect, { key: "ArrowUp" });
|
fireEvent.keyDown(roleSelect, { key: "ArrowUp" });
|
||||||
|
|
||||||
// Should not throw errors
|
// Should not throw errors
|
||||||
expect(roleSelect).toBeInTheDocument();
|
expect(roleSelect).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -292,7 +292,7 @@ describe("Keyboard Navigation Tests", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const chart = screen.getByRole("img", { name: /test chart/i });
|
const chart = screen.getByRole("img", { name: /test chart/i });
|
||||||
|
|
||||||
// Chart should be focusable
|
// Chart should be focusable
|
||||||
chart.focus();
|
chart.focus();
|
||||||
expect(chart).toHaveFocus();
|
expect(chart).toHaveFocus();
|
||||||
@ -311,7 +311,7 @@ describe("Keyboard Navigation Tests", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const chart = screen.getByRole("img", { name: /test chart/i });
|
const chart = screen.getByRole("img", { name: /test chart/i });
|
||||||
|
|
||||||
chart.focus();
|
chart.focus();
|
||||||
|
|
||||||
// Test keyboard interactions
|
// Test keyboard interactions
|
||||||
@ -395,7 +395,7 @@ describe("Keyboard Navigation Tests", () => {
|
|||||||
// Should handle focus on disabled elements gracefully
|
// Should handle focus on disabled elements gracefully
|
||||||
submitButton.focus();
|
submitButton.focus();
|
||||||
fireEvent.keyDown(submitButton, { key: "Enter" });
|
fireEvent.keyDown(submitButton, { key: "Enter" });
|
||||||
|
|
||||||
// Should not cause errors
|
// Should not cause errors
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -515,7 +515,7 @@ describe("Keyboard Navigation Tests", () => {
|
|||||||
await screen.findByText("User Management");
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText("Email");
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
|
||||||
// Focus should still work in high contrast mode
|
// Focus should still work in high contrast mode
|
||||||
emailInput.focus();
|
emailInput.focus();
|
||||||
expect(emailInput).toHaveFocus();
|
expect(emailInput).toHaveFocus();
|
||||||
|
|||||||
@ -263,7 +263,7 @@ describe("UserManagementPage", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
|
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
|
||||||
|
|
||||||
fireEvent.change(emailInput, { target: { value: "invalid-email" } });
|
fireEvent.change(emailInput, { target: { value: "invalid-email" } });
|
||||||
fireEvent.blur(emailInput);
|
fireEvent.blur(emailInput);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user