mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 13:12:10 +01:00
Compare commits
54 Commits
8464ac9c52
...
5042a6c016
| Author | SHA1 | Date | |
|---|---|---|---|
| 5042a6c016 | |||
| 8fd774422c | |||
| 0e526641ce | |||
| 664affae97 | |||
| 9f66463369 | |||
| 2bb90bedd1 | |||
| 93fbb44eec | |||
| 831f344361 | |||
| 86498ec0df | |||
| f5c2af70ef | |||
| 36ed8259b1 | |||
| 2f2c358e67 | |||
| 1972c5e9f7 | |||
| fdb1a9c2b1 | |||
| 60d1b72aba | |||
| aa0e9d5ebc | |||
| ef71c9c06e | |||
| 5a22b860c5 | |||
| 9eb86b0502 | |||
| c5a95edc91 | |||
| 017634f7a8 | |||
| 2a033fe639 | |||
| e027dc9565 | |||
| 3b135a64b5 | |||
| 7f48a085bf | |||
| 192f9497b4 | |||
| 5b22c0f1f8 | |||
| 1be9ce9dd9 | |||
| a6632d6dfc | |||
| 043aa03534 | |||
| 7e59567f73 | |||
| 9238c9a6af | |||
| 8ffd5a7a2c | |||
| 2dfc49f840 | |||
| 185bb6da58 | |||
| 6f9ac219c2 | |||
| 601e2e4026 | |||
| 9a3741cd01 | |||
| f3f63943a8 | |||
| 49a75f5ede | |||
| 5c1ced5900 | |||
| 50b230aa9b | |||
| 1dd618b666 | |||
| d7ac0ba208 | |||
| ab2c75b736 | |||
| 8c43a35632 | |||
| 8f3c1e0f7c | |||
| 0e5ac69d45 | |||
| f964d6a078 | |||
| 944431fea3 | |||
| 1afe15df85 | |||
| 9e095e1a43 | |||
| a9e4145001 | |||
| 3196dabdf2 |
10
.biomeignore
Normal file
10
.biomeignore
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
.git/
|
||||||
|
*.min.js
|
||||||
|
public/
|
||||||
|
prisma/migrations/
|
||||||
|
.claude/
|
||||||
1
.clinerules/pnpm-not-npm.md
Normal file
1
.clinerules/pnpm-not-npm.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
Use pnpm to manage this project, not npm!
|
||||||
@ -1,36 +0,0 @@
|
|||||||
# EditorConfig is awesome: https://EditorConfig.org
|
|
||||||
|
|
||||||
# top-most EditorConfig file
|
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
end_of_line = lf
|
|
||||||
charset = utf-8
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
insert_final_newline = true
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
insert_final_newline = true
|
|
||||||
|
|
||||||
[*.{css,scss,sass}]
|
|
||||||
indent_size = 4
|
|
||||||
indent_style = space
|
|
||||||
|
|
||||||
[*.{ps1,psm1}]
|
|
||||||
indent_size = 4
|
|
||||||
indent_style = tab
|
|
||||||
|
|
||||||
[*.{json,yaml,yml}]
|
|
||||||
indent_size = 2
|
|
||||||
indent_style = space
|
|
||||||
|
|
||||||
[*.{js,ts,jsx,tsx}]
|
|
||||||
indent_size = 2
|
|
||||||
indent_style = space
|
|
||||||
|
|
||||||
[*.{html,htm}]
|
|
||||||
indent_size = 4
|
|
||||||
indent_style = space
|
|
||||||
26
.env.example
Normal file
26
.env.example
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Development environment settings
|
||||||
|
# This file ensures NextAuth always has necessary environment variables in development
|
||||||
|
|
||||||
|
# NextAuth.js configuration
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
NEXTAUTH_SECRET="this_is_a_fixed_secret_for_development_only"
|
||||||
|
NODE_ENV="development"
|
||||||
|
|
||||||
|
# OpenAI API key for session processing
|
||||||
|
# Add your API key here: OPENAI_API_KEY=sk-...
|
||||||
|
OPENAI_API_KEY="your_openai_api_key_here"
|
||||||
|
|
||||||
|
# Database connection - already configured in your prisma/schema.prisma
|
||||||
|
|
||||||
|
# Scheduler Configuration
|
||||||
|
SCHEDULER_ENABLED="false" # Enable/disable all schedulers (false for dev, true for production)
|
||||||
|
CSV_IMPORT_INTERVAL="*/15 * * * *" # Cron expression for CSV imports (every 15 minutes)
|
||||||
|
IMPORT_PROCESSING_INTERVAL="*/5 * * * *" # Cron expression for processing imports to sessions (every 5 minutes)
|
||||||
|
IMPORT_PROCESSING_BATCH_SIZE="50" # Number of imports to process at once
|
||||||
|
SESSION_PROCESSING_INTERVAL="0 * * * *" # Cron expression for AI session processing (every hour)
|
||||||
|
SESSION_PROCESSING_BATCH_SIZE="0" # 0 = unlimited sessions, >0 = specific limit
|
||||||
|
SESSION_PROCESSING_CONCURRENCY="5" # How many sessions to process in parallel
|
||||||
|
|
||||||
|
# Postgres Database Configuration
|
||||||
|
DATABASE_URL_TEST="postgresql://"
|
||||||
|
DATABASE_URL="postgresql://"
|
||||||
29
.env.local.example
Normal file
29
.env.local.example
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Copy this file to .env.local and configure as needed
|
||||||
|
|
||||||
|
# NextAuth.js configuration
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
NEXTAUTH_SECRET="your_secret_key_here"
|
||||||
|
NODE_ENV="development"
|
||||||
|
|
||||||
|
# OpenAI API key for session processing
|
||||||
|
OPENAI_API_KEY="your_openai_api_key_here"
|
||||||
|
|
||||||
|
# Scheduler Configuration
|
||||||
|
SCHEDULER_ENABLED="true" # Set to false to disable all schedulers during development
|
||||||
|
CSV_IMPORT_INTERVAL="*/15 * * * *" # Every 15 minutes (cron format)
|
||||||
|
IMPORT_PROCESSING_INTERVAL="*/5 * * * *" # Every 5 minutes (cron format) - converts imports to sessions
|
||||||
|
IMPORT_PROCESSING_BATCH_SIZE="50" # Number of imports to process at once
|
||||||
|
SESSION_PROCESSING_INTERVAL="0 * * * *" # Every hour (cron format) - AI processing
|
||||||
|
SESSION_PROCESSING_BATCH_SIZE="0" # 0 = process all sessions, >0 = limit number
|
||||||
|
SESSION_PROCESSING_CONCURRENCY="5" # Number of sessions to process in parallel
|
||||||
|
|
||||||
|
# Postgres Database Configuration
|
||||||
|
DATABASE_URL_TEST="postgresql://"
|
||||||
|
DATABASE_URL="postgresql://"
|
||||||
|
|
||||||
|
# Example configurations:
|
||||||
|
# - For development (no schedulers): SCHEDULER_ENABLED=false
|
||||||
|
# - For testing (every 5 minutes): CSV_IMPORT_INTERVAL=*/5 * * * *
|
||||||
|
# - For faster import processing: IMPORT_PROCESSING_INTERVAL=*/2 * * * *
|
||||||
|
# - For limited processing: SESSION_PROCESSING_BATCH_SIZE=10
|
||||||
|
# - For high concurrency: SESSION_PROCESSING_CONCURRENCY=10
|
||||||
2779
.github/instructions/auth.js.instructions.md
vendored
2779
.github/instructions/auth.js.instructions.md
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,335 +0,0 @@
|
|||||||
---
|
|
||||||
applyTo: '**'
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
title: Next.js · Cloudflare Workers docs
|
|
||||||
description: Create an Next.js application and deploy it to Cloudflare Workers with Workers Assets.
|
|
||||||
lastUpdated: 2025-05-16T19:09:44.000Z
|
|
||||||
source_url:
|
|
||||||
html: https://developers.cloudflare.com/workers/frameworks/framework-guides/nextjs/
|
|
||||||
md: https://developers.cloudflare.com/workers/frameworks/framework-guides/nextjs/index.md
|
|
||||||
---
|
|
||||||
|
|
||||||
**Start from CLI** - scaffold a Next.js project on Workers.
|
|
||||||
|
|
||||||
* npm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm create cloudflare@latest -- my-next-app --framework=next
|
|
||||||
```
|
|
||||||
|
|
||||||
* yarn
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn create cloudflare my-next-app --framework=next
|
|
||||||
```
|
|
||||||
|
|
||||||
* pnpm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm create cloudflare@latest my-next-app --framework=next
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a simple getting started guide. For detailed documentation on how the to use the Cloudflare OpenNext adapter, visit the [OpenNext website](https://opennext.js.org/cloudflare).
|
|
||||||
|
|
||||||
## What is Next.js?
|
|
||||||
|
|
||||||
[Next.js](https://nextjs.org/) is a [React](https://react.dev/) framework for building full stack applications.
|
|
||||||
|
|
||||||
Next.js supports Server-side and Client-side rendering, as well as Partial Prerendering which lets you combine static and dynamic components in the same route.
|
|
||||||
|
|
||||||
You can deploy your Next.js app to Cloudflare Workers using the OpenNext adaptor.
|
|
||||||
|
|
||||||
## Next.js supported features
|
|
||||||
|
|
||||||
Most Next.js features are supported by the Cloudflare OpenNext adapter:
|
|
||||||
|
|
||||||
| Feature | Cloudflare adapter | Notes |
|
|
||||||
| - | - | - |
|
|
||||||
| App Router | 🟢 supported | |
|
|
||||||
| Pages Router | 🟢 supported | |
|
|
||||||
| Route Handlers | 🟢 supported | |
|
|
||||||
| React Server Components | 🟢 supported | |
|
|
||||||
| Static Site Generation (SSG) | 🟢 supported | |
|
|
||||||
| Server-Side Rendering (SSR) | 🟢 supported | |
|
|
||||||
| Incremental Static Regeneration (ISR) | 🟢 supported | |
|
|
||||||
| Server Actions | 🟢 supported | |
|
|
||||||
| Response streaming | 🟢 supported | |
|
|
||||||
| asynchronous work with `next/after` | 🟢 supported | |
|
|
||||||
| Middleware | 🟢 supported | |
|
|
||||||
| Image optimization | 🟢 supported | Supported via [Cloudflare Images](https://developers.cloudflare.com/images/) |
|
|
||||||
| Partial Prerendering (PPR) | 🟢 supported | PPR is experimental in Next.js |
|
|
||||||
| Composable Caching ('use cache') | 🟢 supported | Composable Caching is experimental in Next.js |
|
|
||||||
| Node.js in Middleware | ⚪ not yet supported | Node.js middleware introduced in 15.2 are not yet supported |
|
|
||||||
|
|
||||||
## Deploy a new Next.js project on Workers
|
|
||||||
|
|
||||||
1. **Create a new project with the create-cloudflare CLI (C3).**
|
|
||||||
|
|
||||||
* npm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm create cloudflare@latest -- my-next-app --framework=next
|
|
||||||
```
|
|
||||||
|
|
||||||
* yarn
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn create cloudflare my-next-app --framework=next
|
|
||||||
```
|
|
||||||
|
|
||||||
* pnpm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm create cloudflare@latest my-next-app --framework=next
|
|
||||||
```
|
|
||||||
|
|
||||||
What's happening behind the scenes?
|
|
||||||
|
|
||||||
When you run this command, C3 creates a new project directory, initiates [Next.js's official setup tool](https://nextjs.org/docs/app/api-reference/cli/create-next-app), and configures the project for Cloudflare. It then offers the option to instantly deploy your application to Cloudflare.
|
|
||||||
|
|
||||||
2. **Develop locally.**
|
|
||||||
|
|
||||||
After creating your project, run the following command in your project directory to start a local development server. The command uses the Next.js development server. It offers the best developer experience by quickly reloading your app every time the source code is updated.
|
|
||||||
|
|
||||||
* npm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
* yarn
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
* pnpm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Test and preview your site with the Cloudflare adapter.**
|
|
||||||
|
|
||||||
* npm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
* yarn
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
* pnpm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
What's the difference between dev and preview?
|
|
||||||
|
|
||||||
The command used in the previous step uses the Next.js development server, which runs in Node.js. However, your deployed application will run on Cloudflare Workers, which uses the `workerd` runtime. Therefore when running integration tests and previewing your application, you should use the preview command, which is more accurate to production, as it executes your application in the `workerd` runtime using `wrangler dev`.
|
|
||||||
|
|
||||||
4. **Deploy your project.**
|
|
||||||
|
|
||||||
You can deploy your project to a [`*.workers.dev` subdomain](https://developers.cloudflare.com/workers/configuration/routing/workers-dev/) or a [custom domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) from your local machine or any CI/CD system (including [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/#workers-builds)). Use the following command to build and deploy. If you're using a CI service, be sure to update your "deploy command" accordingly.
|
|
||||||
|
|
||||||
* npm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
* yarn
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
* pnpm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deploy an existing Next.js project on Workers
|
|
||||||
|
|
||||||
You can convert an existing Next.js application to run on Cloudflare
|
|
||||||
|
|
||||||
1. **Install [`@opennextjs/cloudflare`](https://www.npmjs.com/package/@opennextjs/cloudflare)**
|
|
||||||
|
|
||||||
* npm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm i @opennextjs/cloudflare@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
* yarn
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn add @opennextjs/cloudflare@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
* pnpm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm add @opennextjs/cloudflare@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install [`wrangler CLI`](https://developers.cloudflare.com/workers/wrangler) as a devDependency**
|
|
||||||
|
|
||||||
* npm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm i -D wrangler@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
* yarn
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn add -D wrangler@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
* pnpm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm add -D wrangler@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Add a Wrangler configuration file**
|
|
||||||
|
|
||||||
In your project root, create a [Wrangler configuration file](https://developers.cloudflare.com/workers/wrangler/configuration/) with the following content:
|
|
||||||
|
|
||||||
* wrangler.jsonc
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "my-app",
|
|
||||||
"compatibility_date": "2025-03-25",
|
|
||||||
"compatibility_flags": [
|
|
||||||
"nodejs_compat"
|
|
||||||
],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
* wrangler.toml
|
|
||||||
|
|
||||||
```toml
|
|
||||||
main = ".open-next/worker.js"
|
|
||||||
name = "my-app"
|
|
||||||
compatibility_date = "2025-03-25"
|
|
||||||
compatibility_flags = ["nodejs_compat"]
|
|
||||||
[assets]
|
|
||||||
directory = ".open-next/assets"
|
|
||||||
binding = "ASSETS"
|
|
||||||
```
|
|
||||||
|
|
||||||
Note
|
|
||||||
|
|
||||||
As shown above, you must enable the [`nodejs_compat` compatibility flag](https://developers.cloudflare.com/workers/runtime-apis/nodejs/) *and* set your [compatibility date](https://developers.cloudflare.com/workers/configuration/compatibility-dates/) to `2024-09-23` or later for your Next.js app to work with @opennextjs/cloudflare.
|
|
||||||
|
|
||||||
4. **Add a configuration file for OpenNext**
|
|
||||||
|
|
||||||
In your project root, create an OpenNext configuration file named `open-next.config.ts` with the following content:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
|
||||||
|
|
||||||
|
|
||||||
export default defineCloudflareConfig();
|
|
||||||
```
|
|
||||||
|
|
||||||
Note
|
|
||||||
|
|
||||||
`open-next.config.ts` is where you can configure the caching, see the [adapter documentation](https://opennext.js.org/cloudflare/caching) for more information
|
|
||||||
|
|
||||||
5. **Update `package.json`**
|
|
||||||
|
|
||||||
You can add the following scripts to your `package.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
|
||||||
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
|
||||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
|
|
||||||
```
|
|
||||||
|
|
||||||
Usage
|
|
||||||
|
|
||||||
* `preview`: Builds your app and serves it locally, allowing you to quickly preview your app running locally in the Workers runtime, via a single command. - `deploy`: Builds your app, and then deploys it to Cloudflare - `cf-typegen`: Generates a `cloudflare-env.d.ts` file at the root of your project containing the types for the env.
|
|
||||||
|
|
||||||
6. **Develop locally.**
|
|
||||||
|
|
||||||
After creating your project, run the following command in your project directory to start a local development server. The command uses the Next.js development server. It offers the best developer experience by quickly reloading your app after your source code is updated.
|
|
||||||
|
|
||||||
* npm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
* yarn
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
* pnpm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Test your site with the Cloudflare adapter.**
|
|
||||||
|
|
||||||
The command used in the previous step uses the Next.js development server to offer a great developer experience. However your application will run on Cloudflare Workers so you want to run your integration tests and verify that your application workers correctly in this environment.
|
|
||||||
|
|
||||||
* npm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
* yarn
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
* pnpm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
8. **Deploy your project.**
|
|
||||||
|
|
||||||
You can deploy your project to a [`*.workers.dev` subdomain](https://developers.cloudflare.com/workers/configuration/routing/workers-dev/) or a [custom domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) from your local machine or any CI/CD system (including [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/#workers-builds)). Use the following command to build and deploy. If you're using a CI service, be sure to update your "deploy command" accordingly.
|
|
||||||
|
|
||||||
* npm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
* yarn
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
* pnpm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm run deploy
|
|
||||||
```
|
|
||||||
17
.github/workflows/playwright.yml
vendored
17
.github/workflows/playwright.yml
vendored
@ -1,13 +1,9 @@
|
|||||||
name: Playwright Tests
|
name: Playwright Tests
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [main, master]
|
||||||
- master
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches: [main, master]
|
||||||
- master
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
@ -18,14 +14,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm install -g pnpm && pnpm install
|
||||||
- name: Build dashboard
|
|
||||||
run: npm run build
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: npx playwright install --with-deps
|
run: pnpm exec playwright install --with-deps
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
continue-on-error: true
|
run: pnpm exec playwright test
|
||||||
run: npx playwright test
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
211
.gitignore
vendored
211
.gitignore
vendored
@ -1,3 +1,5 @@
|
|||||||
|
*-PROGRESS.md
|
||||||
|
|
||||||
# Created by https://www.toptal.com/developers/gitignore/api/node,nextjs,react
|
# Created by https://www.toptal.com/developers/gitignore/api/node,nextjs,react
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node,nextjs,react
|
# Edit at https://www.toptal.com/developers/gitignore?templates=node,nextjs,react
|
||||||
|
|
||||||
@ -33,12 +35,6 @@ yarn-error.log*
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# Cloudflare Workers
|
|
||||||
.dev.vars
|
|
||||||
wrangler.toml.local
|
|
||||||
.wrangler/
|
|
||||||
.open-next/
|
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
@ -224,12 +220,6 @@ yarn-error.log*
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# Cloudflare Workers
|
|
||||||
.dev.vars
|
|
||||||
wrangler.toml.local
|
|
||||||
.wrangler/
|
|
||||||
.open-next/
|
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
@ -239,9 +229,6 @@ next-env.d.ts
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.sqlite?
|
*.sqlite?
|
||||||
*.sqlite
|
|
||||||
*.sqlite3
|
|
||||||
prisma/dev.db
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@ -276,195 +263,7 @@ Thumbs.db
|
|||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
# Created by https://www.toptal.com/developers/gitignore/api/node,macos
|
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos
|
|
||||||
|
|
||||||
### macOS ###
|
# OpenAI API request samples
|
||||||
# General
|
sample-openai-request.json
|
||||||
.DS_Store
|
admin-user.txt
|
||||||
.AppleDouble
|
|
||||||
.LSOverride
|
|
||||||
|
|
||||||
# Icon must end with two \r
|
|
||||||
Icon
|
|
||||||
|
|
||||||
# Thumbnails
|
|
||||||
._*
|
|
||||||
|
|
||||||
# Files that might appear in the root of a volume
|
|
||||||
.DocumentRevisions-V100
|
|
||||||
.fseventsd
|
|
||||||
.Spotlight-V100
|
|
||||||
.TemporaryItems
|
|
||||||
.Trashes
|
|
||||||
.VolumeIcon.icns
|
|
||||||
.com.apple.timemachine.donotpresent
|
|
||||||
|
|
||||||
# Directories potentially created on remote AFP share
|
|
||||||
.AppleDB
|
|
||||||
.AppleDesktop
|
|
||||||
Network Trash Folder
|
|
||||||
Temporary Items
|
|
||||||
.apdisk
|
|
||||||
|
|
||||||
### Node ###
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Moved from ./templates for ignoring all locks in templates
|
|
||||||
templates/**/*-lock.*
|
|
||||||
templates/**/*.lock
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
.temp
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
|
|
||||||
### Node Patch ###
|
|
||||||
# Serverless Webpack directories
|
|
||||||
.webpack/
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
|
|
||||||
# SvelteKit build / generate output
|
|
||||||
.svelte-kit
|
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/node,macos
|
|
||||||
|
|
||||||
# Wrangler output
|
|
||||||
.wrangler/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Turbo output
|
|
||||||
.turbo/
|
|
||||||
|
|
||||||
.dev.vars*
|
|
||||||
test-transcript-format.js
|
|
||||||
my-next-app/
|
|
||||||
|
|
||||||
# Wiki
|
|
||||||
.wiki/
|
|
||||||
|
|
||||||
.specstory/
|
|
||||||
|
|||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@ -1,29 +0,0 @@
|
|||||||
// open-next.config.ts
|
|
||||||
var config = {
|
|
||||||
default: {
|
|
||||||
override: {
|
|
||||||
wrapper: "cloudflare-node",
|
|
||||||
converter: "edge",
|
|
||||||
proxyExternalRequest: "fetch",
|
|
||||||
incrementalCache: "dummy",
|
|
||||||
tagCache: "dummy",
|
|
||||||
queue: "dummy"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
edgeExternals: ["node:crypto"],
|
|
||||||
middleware: {
|
|
||||||
external: true,
|
|
||||||
override: {
|
|
||||||
wrapper: "cloudflare-edge",
|
|
||||||
converter: "edge",
|
|
||||||
proxyExternalRequest: "fetch",
|
|
||||||
incrementalCache: "dummy",
|
|
||||||
tagCache: "dummy",
|
|
||||||
queue: "dummy"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
var open_next_config_default = config;
|
|
||||||
export {
|
|
||||||
open_next_config_default as default
|
|
||||||
};
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import { createRequire as topLevelCreateRequire } from 'module';const require = topLevelCreateRequire(import.meta.url);import bannerUrl from 'url';const __dirname = bannerUrl.fileURLToPath(new URL('.', import.meta.url));
|
|
||||||
|
|
||||||
// open-next.config.ts
|
|
||||||
var config = {
|
|
||||||
default: {
|
|
||||||
override: {
|
|
||||||
wrapper: "cloudflare-node",
|
|
||||||
converter: "edge",
|
|
||||||
proxyExternalRequest: "fetch",
|
|
||||||
incrementalCache: "dummy",
|
|
||||||
tagCache: "dummy",
|
|
||||||
queue: "dummy"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
edgeExternals: ["node:crypto"],
|
|
||||||
middleware: {
|
|
||||||
external: true,
|
|
||||||
override: {
|
|
||||||
wrapper: "cloudflare-edge",
|
|
||||||
converter: "edge",
|
|
||||||
proxyExternalRequest: "fetch",
|
|
||||||
incrementalCache: "dummy",
|
|
||||||
tagCache: "dummy",
|
|
||||||
queue: "dummy"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
var open_next_config_default = config;
|
|
||||||
export {
|
|
||||||
open_next_config_default as default
|
|
||||||
};
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
.pnpm-store/
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
.next/
|
|
||||||
out/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Environment files
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
# Database
|
|
||||||
*.db
|
|
||||||
*.sqlite
|
|
||||||
prisma/migrations/
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Git
|
|
||||||
.git/
|
|
||||||
|
|
||||||
# Coverage reports
|
|
||||||
coverage/
|
|
||||||
|
|
||||||
# Playwright
|
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
playwright/.cache/
|
|
||||||
|
|
||||||
# Generated files
|
|
||||||
*.generated.*
|
|
||||||
|
|
||||||
pnpm-lock.yaml
|
|
||||||
144
CLAUDE.md
Normal file
144
CLAUDE.md
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
**Core Development:**
|
||||||
|
|
||||||
|
- `pnpm dev` - Start development server (runs custom server.ts with schedulers)
|
||||||
|
- `pnpm dev:next-only` - Start Next.js only with Turbopack (no schedulers)
|
||||||
|
- `pnpm build` - Build production application
|
||||||
|
- `pnpm start` - Run production server
|
||||||
|
|
||||||
|
**Code Quality:**
|
||||||
|
|
||||||
|
- `pnpm lint` - Run ESLint
|
||||||
|
- `pnpm lint:fix` - Fix ESLint issues automatically
|
||||||
|
- `pnpm format` - Format code with Prettier
|
||||||
|
- `pnpm format:check` - Check formatting without fixing
|
||||||
|
|
||||||
|
**Database:**
|
||||||
|
|
||||||
|
- `pnpm prisma:generate` - Generate Prisma client
|
||||||
|
- `pnpm prisma:migrate` - Run database migrations
|
||||||
|
- `pnpm prisma:push` - Push schema changes to database
|
||||||
|
- `pnpm prisma:push:force` - Force reset database and push schema
|
||||||
|
- `pnpm prisma:seed` - Seed database with initial data
|
||||||
|
- `pnpm prisma:studio` - Open Prisma Studio database viewer
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
|
||||||
|
- `pnpm test` - Run both Vitest and Playwright tests concurrently
|
||||||
|
- `pnpm test:vitest` - Run Vitest tests only
|
||||||
|
- `pnpm test:vitest:watch` - Run Vitest in watch mode
|
||||||
|
- `pnpm test:vitest:coverage` - Run Vitest with coverage report
|
||||||
|
- `pnpm test:coverage` - Run all tests with coverage
|
||||||
|
|
||||||
|
**Markdown:**
|
||||||
|
|
||||||
|
- `pnpm lint:md` - Lint Markdown files
|
||||||
|
- `pnpm lint:md:fix` - Fix Markdown linting issues
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
**LiveDash-Node** is a real-time analytics dashboard for monitoring user sessions with AI-powered analysis and processing pipeline.
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
- **Frontend:** Next.js 15 + React 19 + TailwindCSS 4
|
||||||
|
- **Backend:** Next.js API Routes + Custom Node.js server
|
||||||
|
- **Database:** PostgreSQL with Prisma ORM
|
||||||
|
- **Authentication:** NextAuth.js
|
||||||
|
- **AI Processing:** OpenAI API integration
|
||||||
|
- **Visualization:** D3.js, React Leaflet, Recharts
|
||||||
|
- **Scheduling:** Node-cron for background processing
|
||||||
|
|
||||||
|
### Key Architecture Components
|
||||||
|
|
||||||
|
**1. Multi-Stage Processing Pipeline**
|
||||||
|
The system processes user sessions through distinct stages tracked in `SessionProcessingStatus`:
|
||||||
|
|
||||||
|
- `CSV_IMPORT` - Import raw CSV data into `SessionImport`
|
||||||
|
- `TRANSCRIPT_FETCH` - Fetch transcript content from URLs
|
||||||
|
- `SESSION_CREATION` - Create normalized `Session` and `Message` records
|
||||||
|
- `AI_ANALYSIS` - AI processing for sentiment, categorization, summaries
|
||||||
|
- `QUESTION_EXTRACTION` - Extract questions from conversations
|
||||||
|
|
||||||
|
**2. Database Architecture**
|
||||||
|
|
||||||
|
- **Multi-tenant design** with `Company` as root entity
|
||||||
|
- **Dual storage pattern**: Raw CSV data in `SessionImport`, processed data in `Session`
|
||||||
|
- **1-to-1 relationship** between `SessionImport` and `Session` via `importId`
|
||||||
|
- **Message parsing** into individual `Message` records with order tracking
|
||||||
|
- **AI cost tracking** via `AIProcessingRequest` with detailed token usage
|
||||||
|
- **Flexible AI model management** through `AIModel`, `AIModelPricing`, and `CompanyAIModel`
|
||||||
|
|
||||||
|
**3. Custom Server Architecture**
|
||||||
|
|
||||||
|
- `server.ts` - Custom Next.js server with configurable scheduler initialization
|
||||||
|
- Three main schedulers: CSV import, import processing, and session processing
|
||||||
|
- Environment-based configuration via `lib/env.ts`
|
||||||
|
|
||||||
|
**4. Key Processing Libraries**
|
||||||
|
|
||||||
|
- `lib/scheduler.ts` - CSV import scheduling
|
||||||
|
- `lib/importProcessor.ts` - Raw data to Session conversion
|
||||||
|
- `lib/processingScheduler.ts` - AI analysis pipeline
|
||||||
|
- `lib/transcriptFetcher.ts` - External transcript fetching
|
||||||
|
- `lib/transcriptParser.ts` - Message parsing from transcripts
|
||||||
|
|
||||||
|
### Development Environment
|
||||||
|
|
||||||
|
**Environment Configuration:**
|
||||||
|
Environment variables are managed through `lib/env.ts` with .env.local file support:
|
||||||
|
|
||||||
|
- Database: PostgreSQL via `DATABASE_URL` and `DATABASE_URL_DIRECT`
|
||||||
|
- Authentication: `NEXTAUTH_SECRET`, `NEXTAUTH_URL`
|
||||||
|
- AI Processing: `OPENAI_API_KEY`
|
||||||
|
- Schedulers: `SCHEDULER_ENABLED`, various interval configurations
|
||||||
|
|
||||||
|
**Key Files to Understand:**
|
||||||
|
|
||||||
|
- `prisma/schema.prisma` - Complete database schema with enums and relationships
|
||||||
|
- `server.ts` - Custom server entry point
|
||||||
|
- `lib/env.ts` - Environment variable management and validation
|
||||||
|
- `app/` - Next.js App Router structure
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
|
||||||
|
- Uses Vitest for unit testing
|
||||||
|
- Playwright for E2E testing
|
||||||
|
- Test files in `tests/` directory
|
||||||
|
|
||||||
|
### Important Notes
|
||||||
|
|
||||||
|
**Scheduler System:**
|
||||||
|
|
||||||
|
- Schedulers are optional and controlled by `SCHEDULER_ENABLED` environment variable
|
||||||
|
- Use `pnpm dev:next-only` to run without schedulers for pure frontend development
|
||||||
|
- Three separate schedulers handle different pipeline stages:
|
||||||
|
- CSV Import Scheduler (`lib/scheduler.ts`)
|
||||||
|
- Import Processing Scheduler (`lib/importProcessor.ts`)
|
||||||
|
- Session Processing Scheduler (`lib/processingScheduler.ts`)
|
||||||
|
|
||||||
|
**Database Migrations:**
|
||||||
|
|
||||||
|
- Always run `pnpm prisma:generate` after schema changes
|
||||||
|
- Use `pnpm prisma:migrate` for production-ready migrations
|
||||||
|
- Use `pnpm prisma:push` for development schema changes
|
||||||
|
- Database uses PostgreSQL with Prisma's driver adapter for connection pooling
|
||||||
|
|
||||||
|
**AI Processing:**
|
||||||
|
|
||||||
|
- All AI requests are tracked for cost analysis
|
||||||
|
- Support for multiple AI models per company
|
||||||
|
- Time-based pricing management for accurate cost calculation
|
||||||
|
- Processing stages can be retried on failure with retry count tracking
|
||||||
|
|
||||||
|
**Code Quality Standards:**
|
||||||
|
|
||||||
|
- Run `pnpm lint` and `pnpm format:check` before committing
|
||||||
|
- TypeScript with ES modules (type: "module" in package.json)
|
||||||
|
- React 19 with Next.js 15 App Router
|
||||||
|
- TailwindCSS 4 for styling
|
||||||
91
FIXES-APPLIED.md
Normal file
91
FIXES-APPLIED.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# 🚨 Database Connection Issues - Fixes Applied
|
||||||
|
|
||||||
|
## Issues Identified
|
||||||
|
|
||||||
|
From your logs:
|
||||||
|
```
|
||||||
|
Can't reach database server at `ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.neon.tech:5432`
|
||||||
|
[NODE-CRON] [WARN] missed execution! Possible blocking IO or high CPU
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root Causes
|
||||||
|
|
||||||
|
1. **Multiple PrismaClient instances** across schedulers
|
||||||
|
2. **No connection retry logic** for temporary failures
|
||||||
|
3. **No connection pooling optimization** for Neon
|
||||||
|
4. **Aggressive scheduler intervals** overwhelming database
|
||||||
|
|
||||||
|
## Fixes Applied ✅
|
||||||
|
|
||||||
|
### 1. Connection Retry Logic (`lib/database-retry.ts`)
|
||||||
|
- **Automatic retry** for connection errors
|
||||||
|
- **Exponential backoff** (1s → 2s → 4s → 10s max)
|
||||||
|
- **Smart error detection** (only retry connection issues)
|
||||||
|
- **Configurable retry attempts** (default: 3 retries)
|
||||||
|
|
||||||
|
### 2. Enhanced Schedulers
|
||||||
|
- **Import Processor**: Added retry wrapper around main processing
|
||||||
|
- **Session Processor**: Added retry wrapper around AI processing
|
||||||
|
- **Graceful degradation** when database is temporarily unavailable
|
||||||
|
|
||||||
|
### 3. Singleton Pattern Enforced
|
||||||
|
- **All schedulers now use** `import { prisma } from "./prisma.js"`
|
||||||
|
- **No more separate** `new PrismaClient()` instances
|
||||||
|
- **Shared connection pool** across all operations
|
||||||
|
|
||||||
|
### 4. Neon-Specific Optimizations
|
||||||
|
- **Connection limit guidance**: 15 connections (below Neon's 20 limit)
|
||||||
|
- **Extended timeouts**: 30s for cold start handling
|
||||||
|
- **SSL mode requirements**: `sslmode=require` for Neon
|
||||||
|
- **Application naming**: For better monitoring
|
||||||
|
|
||||||
|
## Immediate Actions Needed
|
||||||
|
|
||||||
|
### 1. Update Environment Variables
|
||||||
|
```bash
|
||||||
|
# Add to .env.local
|
||||||
|
USE_ENHANCED_POOLING=true
|
||||||
|
DATABASE_CONNECTION_LIMIT=15
|
||||||
|
DATABASE_POOL_TIMEOUT=30
|
||||||
|
|
||||||
|
# Update your DATABASE_URL to include:
|
||||||
|
DATABASE_URL="postgresql://user:pass@ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.neon.tech:5432/db?sslmode=require&connection_limit=15&pool_timeout=30"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Reduce Scheduler Frequency (Optional)
|
||||||
|
```bash
|
||||||
|
# Less aggressive intervals
|
||||||
|
CSV_IMPORT_INTERVAL="*/30 * * * *" # Every 30 min (was 15)
|
||||||
|
IMPORT_PROCESSING_INTERVAL="*/10 * * * *" # Every 10 min (was 5)
|
||||||
|
SESSION_PROCESSING_INTERVAL="0 */2 * * *" # Every 2 hours (was 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run Configuration Check
|
||||||
|
```bash
|
||||||
|
pnpm db:check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected Results
|
||||||
|
|
||||||
|
✅ **Connection Stability**: Automatic retry on temporary failures
|
||||||
|
✅ **Resource Efficiency**: Single shared connection pool
|
||||||
|
✅ **Neon Optimization**: Proper connection limits and timeouts
|
||||||
|
✅ **Monitoring**: Health check endpoint for visibility
|
||||||
|
✅ **Graceful Degradation**: Schedulers won't crash on DB issues
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
- **Health Endpoint**: `/api/admin/database-health`
|
||||||
|
- **Connection Logs**: Enhanced logging for pool events
|
||||||
|
- **Retry Logs**: Detailed retry attempt logging
|
||||||
|
- **Error Classification**: Retryable vs non-retryable errors
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `lib/database-retry.ts` - New retry utilities
|
||||||
|
- `lib/importProcessor.ts` - Added retry wrapper
|
||||||
|
- `lib/processingScheduler.ts` - Added retry wrapper
|
||||||
|
- `docs/neon-database-optimization.md` - Neon-specific guide
|
||||||
|
- `scripts/check-database-config.ts` - Configuration checker
|
||||||
|
|
||||||
|
The connection issues should be significantly reduced with these fixes! 🎯
|
||||||
62
README.md
62
README.md
@ -6,7 +6,7 @@ A real-time analytics dashboard for monitoring user sessions and interactions wi
|
|||||||
.*%22&replace=%24%3Cversion%3E&logo=react&label=React&color=%2361DAFB>)
|
.*%22&replace=%24%3Cversion%3E&logo=react&label=React&color=%2361DAFB>)
|
||||||
.*%22&replace=%24%3Cversion%3E&logo=typescript&label=TypeScript&color=%233178C6>)
|
.*%22&replace=%24%3Cversion%3E&logo=typescript&label=TypeScript&color=%233178C6>)
|
||||||
.*%22&replace=%24%3Cversion%3E&logo=prisma&label=Prisma&color=%232D3748>)
|
.*%22&replace=%24%3Cversion%3E&logo=prisma&label=Prisma&color=%232D3748>)
|
||||||
.*%22&replace=%24%3Cversion%3E&logo=tailwindcss&label=TailwindCSS&color=%2306B6D4>)
|
.*%22&replace=%24%3Cversion%3E&logo=tailwindcss&label=TailwindCSS&color=%2306B6D4>)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@ -31,36 +31,36 @@ A real-time analytics dashboard for monitoring user sessions and interactions wi
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js (LTS version recommended)
|
- Node.js (LTS version recommended)
|
||||||
- npm or yarn
|
- pnpm (recommended package manager)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
1. Clone this repository:
|
1. Clone this repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/kjanat/livedash-node.git
|
git clone https://github.com/kjanat/livedash-node.git
|
||||||
cd livedash-node
|
cd livedash-node
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Set up the database:
|
3. Set up the database:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run prisma:generate
|
pnpm run prisma:generate
|
||||||
npm run prisma:migrate
|
pnpm run prisma:migrate
|
||||||
npm run prisma:seed
|
pnpm run prisma:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Start the development server:
|
4. Start the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Open your browser and navigate to <http://localhost:3000>
|
5. Open your browser and navigate to <http://localhost:3000>
|
||||||
|
|
||||||
@ -86,19 +86,19 @@ NEXTAUTH_SECRET=your-secret-here
|
|||||||
|
|
||||||
## Available Scripts
|
## Available Scripts
|
||||||
|
|
||||||
- `npm run dev`: Start the development server
|
- `pnpm run dev`: Start the development server
|
||||||
- `npm run build`: Build the application for production
|
- `pnpm run build`: Build the application for production
|
||||||
- `npm run start`: Run the production build
|
- `pnpm run start`: Run the production build
|
||||||
- `npm run lint`: Run ESLint
|
- `pnpm run lint`: Run ESLint
|
||||||
- `npm run format`: Format code with Prettier
|
- `pnpm run format`: Format code with Prettier
|
||||||
- `npm run prisma:studio`: Open Prisma Studio to view database
|
- `pnpm run prisma:studio`: Open Prisma Studio to view database
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create your feature branch: `git checkout -b feature/my-new-feature`
|
2. Create your feature branch: `git checkout -b feature/my-new-feature`
|
||||||
3. Commit your changes: `git commit -am 'Add some feature'`
|
3. Commit your changes: `git commit -am 'Add some feature'`
|
||||||
4. Push to the branch: `git push origin feature/my-new-feature`
|
4. Push to the branch: `git push origin feature/my-new-feature`
|
||||||
5. Submit a pull request
|
5. Submit a pull request
|
||||||
|
|
||||||
## License
|
## License
|
||||||
@ -107,9 +107,9 @@ This project is not licensed for commercial use without explicit permission. Fre
|
|||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org/)
|
- [Next.js](https://nextjs.org/)
|
||||||
- [Prisma](https://prisma.io/)
|
- [Prisma](https://prisma.io/)
|
||||||
- [TailwindCSS](https://tailwindcss.com/)
|
- [TailwindCSS](https://tailwindcss.com/)
|
||||||
- [Chart.js](https://www.chartjs.org/)
|
- [Chart.js](https://www.chartjs.org/)
|
||||||
- [D3.js](https://d3js.org/)
|
- [D3.js](https://d3js.org/)
|
||||||
- [React Leaflet](https://react-leaflet.js.org/)
|
- [React Leaflet](https://react-leaflet.js.org/)
|
||||||
|
|||||||
247
TODO
Normal file
247
TODO
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
# TODO - LiveDash Architecture Evolution & Improvements
|
||||||
|
|
||||||
|
## 🚀 CRITICAL PRIORITY - Architectural Refactoring
|
||||||
|
|
||||||
|
### Phase 1: Service Decomposition & Platform Management (Weeks 1-4)
|
||||||
|
- [x] **Create Platform Management Layer** (80% Complete)
|
||||||
|
- [x] Add Organization/PlatformUser models to Prisma schema
|
||||||
|
- [x] Implement super-admin authentication system (/platform/login)
|
||||||
|
- [x] Build platform dashboard for Notso AI team (/platform/dashboard)
|
||||||
|
- [x] Add company creation workflows
|
||||||
|
- [x] Add basic platform API endpoints with tests
|
||||||
|
- [x] Create stunning SaaS landing page with modern design
|
||||||
|
- [x] Add company editing/management workflows
|
||||||
|
- [x] Create company suspension/activation UI features
|
||||||
|
- [x] Add proper SEO metadata and OpenGraph tags
|
||||||
|
- [x] Add user management within companies from platform
|
||||||
|
- [ ] Add AI model management UI
|
||||||
|
- [ ] Add cost tracking/quotas UI
|
||||||
|
|
||||||
|
- [ ] **Extract Data Ingestion Service (Golang)**
|
||||||
|
- [ ] Create new Golang service for CSV processing
|
||||||
|
- [ ] Implement concurrent CSV downloading & parsing
|
||||||
|
- [ ] Add transcript fetching with rate limiting
|
||||||
|
- [ ] Set up Redis message queues (BullMQ/RabbitMQ)
|
||||||
|
- [ ] Migrate lib/scheduler.ts and lib/csvFetcher.ts logic
|
||||||
|
|
||||||
|
- [ ] **Implement tRPC Infrastructure**
|
||||||
|
- [ ] Add tRPC to existing Next.js app
|
||||||
|
- [ ] Create type-safe API procedures for frontend
|
||||||
|
- [ ] Implement inter-service communication protocols
|
||||||
|
- [ ] Add proper error handling and validation
|
||||||
|
|
||||||
|
### Phase 2: AI Service Separation & Compliance (Weeks 5-8)
|
||||||
|
- [ ] **Extract AI Processing Service**
|
||||||
|
- [ ] Separate lib/processingScheduler.ts into standalone service
|
||||||
|
- [ ] Implement async AI processing with queues
|
||||||
|
- [ ] Add per-company AI cost tracking and quotas
|
||||||
|
- [ ] Create AI model management per company
|
||||||
|
- [ ] Add retry logic and failure handling
|
||||||
|
|
||||||
|
- [ ] **GDPR & ISO 27001 Compliance Foundation**
|
||||||
|
- [ ] Implement data isolation boundaries between services
|
||||||
|
- [ ] Add audit logging for all data processing
|
||||||
|
- [ ] Create data retention policies per company
|
||||||
|
- [ ] Add consent management for data processing
|
||||||
|
- [ ] Implement data export/deletion workflows (Right to be Forgotten)
|
||||||
|
|
||||||
|
### Phase 3: Performance & Monitoring (Weeks 9-12)
|
||||||
|
- [ ] **Monitoring & Observability**
|
||||||
|
- [ ] Add distributed tracing across services (Jaeger/Zipkin)
|
||||||
|
- [ ] Implement health checks for all services
|
||||||
|
- [ ] Create cross-service metrics dashboard
|
||||||
|
- [ ] Add alerting for service failures and SLA breaches
|
||||||
|
- [ ] Monitor AI processing costs and quotas
|
||||||
|
|
||||||
|
- [ ] **Database Optimization**
|
||||||
|
- [ ] Implement connection pooling per service
|
||||||
|
- [ ] Add read replicas for dashboard queries
|
||||||
|
- [ ] Create database sharding strategy for multi-tenancy
|
||||||
|
- [ ] Optimize queries with proper indexing
|
||||||
|
|
||||||
|
## High Priority
|
||||||
|
|
||||||
|
### PR #20 Feedback Actions (Code Review)
|
||||||
|
- [ ] **Fix Environment Variable Testing**
|
||||||
|
- [ ] Replace process.env access with proper environment mocking in tests
|
||||||
|
- [ ] Update existing tests to avoid direct environment variable dependencies
|
||||||
|
- [ ] Add environment validation tests for critical config values
|
||||||
|
|
||||||
|
- [ ] **Enforce Zero Accessibility Violations**
|
||||||
|
- [ ] Set Playwright accessibility tests to fail on any violations (not just warn)
|
||||||
|
- [ ] Add accessibility regression tests for all major components
|
||||||
|
- [ ] Implement accessibility checklist for new components
|
||||||
|
|
||||||
|
- [ ] **Improve Error Handling with Custom Error Classes**
|
||||||
|
- [ ] Create custom error classes for different error types (ValidationError, AuthError, etc.)
|
||||||
|
- [ ] Replace generic Error throws with specific error classes
|
||||||
|
- [ ] Add proper error logging and monitoring integration
|
||||||
|
|
||||||
|
- [ ] **Refactor Long className Strings**
|
||||||
|
- [ ] Extract complex className combinations into utility functions
|
||||||
|
- [ ] Consider using cn() utility from utils for cleaner class composition
|
||||||
|
- [ ] Break down overly complex className props into semantic components
|
||||||
|
|
||||||
|
- [ ] **Add Dark Mode Accessibility Tests**
|
||||||
|
- [ ] Create comprehensive test suite for dark mode color contrast
|
||||||
|
- [ ] Verify focus indicators work properly in both light and dark modes
|
||||||
|
- [ ] Test screen reader compatibility with theme switching
|
||||||
|
|
||||||
|
- [ ] **Fix Platform Login Authentication Issue**
|
||||||
|
- [ ] NEXTAUTH_SECRET was using placeholder value (FIXED)
|
||||||
|
- [ ] Investigate platform cookie path restrictions in /platform auth
|
||||||
|
- [ ] Test platform login flow end-to-end after fixes
|
||||||
|
|
||||||
|
### Testing & Quality Assurance
|
||||||
|
- [ ] Add comprehensive test coverage for API endpoints (currently minimal)
|
||||||
|
- [ ] Implement integration tests for the data processing pipeline
|
||||||
|
- [ ] Add unit tests for validation schemas and authentication logic
|
||||||
|
- [ ] Create E2E tests for critical user flows (registration, login, dashboard)
|
||||||
|
|
||||||
|
### Error Handling & Monitoring
|
||||||
|
- [ ] Implement global error boundaries for React components
|
||||||
|
- [ ] Add structured logging with correlation IDs for request tracing
|
||||||
|
- [ ] Set up error monitoring and alerting (e.g., Sentry integration)
|
||||||
|
- [ ] Add proper error pages for 404, 500, and other HTTP status codes
|
||||||
|
|
||||||
|
### Performance Optimization
|
||||||
|
- [ ] Implement database query optimization and indexing strategy
|
||||||
|
- [ ] Add caching layer for frequently accessed data (Redis/in-memory)
|
||||||
|
- [ ] Optimize React components with proper memoization
|
||||||
|
- [ ] Implement lazy loading for dashboard components and charts
|
||||||
|
|
||||||
|
## Medium Priority
|
||||||
|
|
||||||
|
### Security Enhancements
|
||||||
|
- [ ] Add CSRF protection for state-changing operations
|
||||||
|
- [ ] Implement session timeout and refresh token mechanism
|
||||||
|
- [ ] Add API rate limiting with Redis-backed storage (replace in-memory)
|
||||||
|
- [ ] Implement role-based access control (RBAC) for different user types
|
||||||
|
- [ ] Add audit logging for sensitive operations
|
||||||
|
|
||||||
|
### Code Quality & Maintenance
|
||||||
|
- [ ] Resolve remaining ESLint warnings and type issues
|
||||||
|
- [ ] Standardize chart library usage (currently mixing Chart.js and other libraries)
|
||||||
|
- [ ] Add proper TypeScript strict mode configuration
|
||||||
|
- [ ] Implement consistent API response formats across all endpoints
|
||||||
|
|
||||||
|
### Database & Schema
|
||||||
|
- [ ] Add database connection pooling configuration
|
||||||
|
- [ ] Implement proper database migrations for production deployment
|
||||||
|
- [ ] Add data retention policies for session data
|
||||||
|
- [ ] Consider database partitioning for large-scale data
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- [ ] Add loading states and skeleton components throughout the application
|
||||||
|
- [ ] Implement proper form validation feedback and error messages
|
||||||
|
- [ ] Add pagination for large data sets in dashboard tables
|
||||||
|
- [ ] Implement real-time notifications for processing status updates
|
||||||
|
|
||||||
|
## Low Priority
|
||||||
|
|
||||||
|
### Documentation & Development
|
||||||
|
- [ ] Add API documentation (OpenAPI/Swagger)
|
||||||
|
- [ ] Create deployment guides for different environments
|
||||||
|
- [ ] Add contributing guidelines and code review checklist
|
||||||
|
- [ ] Implement development environment setup automation
|
||||||
|
|
||||||
|
### Feature Enhancements
|
||||||
|
- [ ] Add data export functionality (CSV, PDF reports)
|
||||||
|
- [ ] Implement dashboard customization and user preferences
|
||||||
|
- [ ] Add multi-language support (i18n)
|
||||||
|
- [ ] Create admin panel for system configuration
|
||||||
|
|
||||||
|
### Infrastructure & DevOps
|
||||||
|
- [ ] Add Docker configuration for containerized deployment
|
||||||
|
- [ ] Implement CI/CD pipeline with automated testing
|
||||||
|
- [ ] Add environment-specific configuration management
|
||||||
|
- [ ] Set up monitoring and health check endpoints
|
||||||
|
|
||||||
|
### Analytics & Insights
|
||||||
|
- [ ] Add more detailed analytics and reporting features
|
||||||
|
- [ ] Implement A/B testing framework for UI improvements
|
||||||
|
- [ ] Add user behavior tracking and analytics
|
||||||
|
- [ ] Create automated report generation and scheduling
|
||||||
|
|
||||||
|
## Completed ✅
|
||||||
|
- [x] Fix duplicate MetricCard components
|
||||||
|
- [x] Add input validation schema with Zod
|
||||||
|
- [x] Strengthen password requirements (12+ chars, complexity)
|
||||||
|
- [x] Fix schema drift - create missing migrations
|
||||||
|
- [x] Add rate limiting to authentication endpoints
|
||||||
|
- [x] Update README.md to use pnpm instead of npm
|
||||||
|
- [x] Implement platform authentication and basic dashboard
|
||||||
|
- [x] Add platform API endpoints for company management
|
||||||
|
- [x] Write tests for platform features (auth, dashboard, API)
|
||||||
|
|
||||||
|
## 📊 Test Coverage Status (< 30% Overall)
|
||||||
|
|
||||||
|
### ✅ Features WITH Tests:
|
||||||
|
- User Authentication (regular users)
|
||||||
|
- User Management UI & API
|
||||||
|
- Basic database connectivity
|
||||||
|
- Transcript Fetcher
|
||||||
|
- Input validation
|
||||||
|
- Environment configuration
|
||||||
|
- Format enums
|
||||||
|
- Accessibility features
|
||||||
|
- Keyboard navigation
|
||||||
|
- Platform authentication (NEW)
|
||||||
|
- Platform dashboard (NEW)
|
||||||
|
- Platform API endpoints (NEW)
|
||||||
|
|
||||||
|
### ❌ Features WITHOUT Tests (Critical Gaps):
|
||||||
|
- **Data Processing Pipeline** (0 tests)
|
||||||
|
- CSV import scheduler
|
||||||
|
- Import processor
|
||||||
|
- Processing scheduler
|
||||||
|
- AI processing functionality
|
||||||
|
- Transcript parser
|
||||||
|
- **Most API Endpoints** (0 tests)
|
||||||
|
- Dashboard endpoints
|
||||||
|
- Session management
|
||||||
|
- Admin endpoints
|
||||||
|
- Password reset flow
|
||||||
|
- **Custom Server** (0 tests)
|
||||||
|
- **Dashboard Features** (0 tests)
|
||||||
|
- Charts and visualizations
|
||||||
|
- Session details
|
||||||
|
- Company settings
|
||||||
|
- **AI Integration** (0 tests)
|
||||||
|
- **Real-time Features** (0 tests)
|
||||||
|
- **E2E Tests** (only examples exist)
|
||||||
|
|
||||||
|
## 🏛️ Architectural Decisions & Rationale
|
||||||
|
|
||||||
|
### Service Technology Choices
|
||||||
|
- **Dashboard Service**: Next.js + tRPC (existing, proven stack)
|
||||||
|
- **Data Ingestion Service**: Golang (high-performance CSV processing, concurrency)
|
||||||
|
- **AI Processing Service**: Node.js/Python (existing AI integrations, async processing)
|
||||||
|
- **Message Queue**: Redis + BullMQ (Node.js ecosystem compatibility)
|
||||||
|
- **Database**: PostgreSQL (existing, excellent for multi-tenancy)
|
||||||
|
|
||||||
|
### Why Golang for Data Ingestion?
|
||||||
|
- **Performance**: 10-100x faster CSV processing than Node.js
|
||||||
|
- **Concurrency**: Native goroutines for parallel transcript fetching
|
||||||
|
- **Memory Efficiency**: Lower memory footprint for large CSV files
|
||||||
|
- **Deployment**: Single binary deployment, excellent for containers
|
||||||
|
- **Team Growth**: Easy to hire Golang developers for data processing
|
||||||
|
|
||||||
|
### Migration Strategy
|
||||||
|
1. **Keep existing working system** while building new services
|
||||||
|
2. **Feature flagging** to gradually migrate companies to new processing
|
||||||
|
3. **Dual-write approach** during transition period
|
||||||
|
4. **Zero-downtime migration** with careful rollback plans
|
||||||
|
|
||||||
|
### Compliance Benefits
|
||||||
|
- **Data Isolation**: Each service has limited database access
|
||||||
|
- **Audit Trail**: All inter-service communication logged
|
||||||
|
- **Data Retention**: Automated per-company data lifecycle
|
||||||
|
- **Security Boundaries**: DMZ for ingestion, private network for processing
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- **CRITICAL**: Architectural refactoring must be priority #1 for scalability
|
||||||
|
- **Platform Management**: Notso AI needs self-service customer onboarding
|
||||||
|
- **Compliance First**: GDPR/ISO 27001 requirements drive service boundaries
|
||||||
|
- **Performance**: Current monolith blocks on CSV/AI processing
|
||||||
|
- **Technology Evolution**: Golang for data processing, tRPC for type safety
|
||||||
108
TODO.md
108
TODO.md
@ -1,108 +0,0 @@
|
|||||||
# TODO.md
|
|
||||||
|
|
||||||
## Dashboard Integration
|
|
||||||
|
|
||||||
- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation**
|
|
||||||
- Investigate integrating real data sources with server-side analytics
|
|
||||||
- Replace simulated data mentioned in `docs/dashboard-components.md`
|
|
||||||
|
|
||||||
## Component Specific
|
|
||||||
|
|
||||||
- [ ] **Implement robust emailing of temporary passwords**
|
|
||||||
|
|
||||||
- File: `pages/api/dashboard/users.ts`
|
|
||||||
- Set up proper email service integration
|
|
||||||
|
|
||||||
- [x] **Session page improvements** ✅
|
|
||||||
- File: `app/dashboard/sessions/page.tsx`
|
|
||||||
- Implemented pagination, advanced filtering, and sorting
|
|
||||||
|
|
||||||
## File Cleanup
|
|
||||||
|
|
||||||
- [x] **Remove backup files** ✅
|
|
||||||
- Reviewed and removed `.bak` and `.new` files after integration
|
|
||||||
- Cleaned up `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`
|
|
||||||
|
|
||||||
## Database Schema Improvements
|
|
||||||
|
|
||||||
- [ ] **Update EndTime field**
|
|
||||||
|
|
||||||
- Make `endTime` field nullable in Prisma schema to match TypeScript interfaces
|
|
||||||
|
|
||||||
- [ ] **Add database indices**
|
|
||||||
|
|
||||||
- Add appropriate indices to improve query performance
|
|
||||||
- Focus on dashboard metrics and session listing queries
|
|
||||||
|
|
||||||
- [ ] **Implement production email service**
|
|
||||||
- Replace console logging in `lib/sendEmail.ts`
|
|
||||||
- Consider providers: Nodemailer, SendGrid, AWS SES
|
|
||||||
|
|
||||||
## General Enhancements & Features
|
|
||||||
|
|
||||||
- [ ] **Real-time updates**
|
|
||||||
|
|
||||||
- Implement for dashboard and session list
|
|
||||||
- Consider WebSockets or Server-Sent Events
|
|
||||||
|
|
||||||
- [ ] **Data export functionality**
|
|
||||||
|
|
||||||
- Allow users (especially admins) to export session data
|
|
||||||
- Support CSV format initially
|
|
||||||
|
|
||||||
- [ ] **Customizable dashboard**
|
|
||||||
- Allow users to customize dashboard view
|
|
||||||
- Let users choose which metrics/charts are most important
|
|
||||||
|
|
||||||
## Testing & Quality Assurance
|
|
||||||
|
|
||||||
- [ ] **Comprehensive testing suite**
|
|
||||||
|
|
||||||
- [ ] Unit tests for utility functions and API logic
|
|
||||||
- [ ] Integration tests for API endpoints with database
|
|
||||||
- [ ] End-to-end tests for user flows (Playwright or Cypress)
|
|
||||||
|
|
||||||
- [ ] **Error monitoring and logging**
|
|
||||||
|
|
||||||
- Integrate robust error monitoring service (Sentry)
|
|
||||||
- Enhance server-side logging
|
|
||||||
|
|
||||||
- [ ] **Accessibility improvements**
|
|
||||||
- Review application against WCAG guidelines
|
|
||||||
- Improve keyboard navigation and screen reader compatibility
|
|
||||||
- Check color contrast ratios
|
|
||||||
|
|
||||||
## Security Enhancements
|
|
||||||
|
|
||||||
- [x] **Password reset functionality** ✅
|
|
||||||
|
|
||||||
- Implemented secure password reset mechanism
|
|
||||||
- Files: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts`
|
|
||||||
|
|
||||||
- [ ] **Two-Factor Authentication (2FA)**
|
|
||||||
|
|
||||||
- Consider adding 2FA, especially for admin accounts
|
|
||||||
|
|
||||||
- [ ] **Input validation and sanitization**
|
|
||||||
- Review all user inputs (API request bodies, query parameters)
|
|
||||||
- Ensure proper validation and sanitization
|
|
||||||
|
|
||||||
## Code Quality & Development
|
|
||||||
|
|
||||||
- [ ] **Code review process**
|
|
||||||
|
|
||||||
- Enforce code reviews for all changes
|
|
||||||
|
|
||||||
- [ ] **Environment configuration**
|
|
||||||
|
|
||||||
- Ensure secure management of environment-specific configurations
|
|
||||||
|
|
||||||
- [ ] **Dependency management**
|
|
||||||
|
|
||||||
- Periodically review dependencies for vulnerabilities
|
|
||||||
- Keep dependencies updated
|
|
||||||
|
|
||||||
- [ ] **Documentation updates**
|
|
||||||
- [ ] Ensure `docs/dashboard-components.md` reflects actual implementations
|
|
||||||
- [ ] Verify "Dashboard Enhancements" are consistently applied
|
|
||||||
- [ ] Update documentation for improved layout and visual hierarchies
|
|
||||||
89
app/api/admin/database-health/route.ts
Normal file
89
app/api/admin/database-health/route.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
// Database connection health monitoring endpoint
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { checkDatabaseConnection, prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Check if user has admin access (you may want to add proper auth here)
|
||||||
|
const authHeader = request.headers.get("authorization");
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic database connectivity check
|
||||||
|
const isConnected = await checkDatabaseConnection();
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: "unhealthy",
|
||||||
|
database: {
|
||||||
|
connected: false,
|
||||||
|
error: "Database connection failed",
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get basic metrics
|
||||||
|
const metrics = await Promise.allSettled([
|
||||||
|
// Count total sessions
|
||||||
|
prisma.session.count(),
|
||||||
|
// Count processing status records
|
||||||
|
prisma.sessionProcessingStatus.count(),
|
||||||
|
// Count recent AI requests
|
||||||
|
prisma.aIProcessingRequest.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [sessionsResult, statusResult, aiRequestsResult] = metrics;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "healthy",
|
||||||
|
database: {
|
||||||
|
connected: true,
|
||||||
|
connectionType:
|
||||||
|
process.env.USE_ENHANCED_POOLING === "true"
|
||||||
|
? "enhanced_pooling"
|
||||||
|
: "standard",
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
totalSessions:
|
||||||
|
sessionsResult.status === "fulfilled"
|
||||||
|
? sessionsResult.value
|
||||||
|
: "error",
|
||||||
|
processingRecords:
|
||||||
|
statusResult.status === "fulfilled" ? statusResult.value : "error",
|
||||||
|
recentAIRequests:
|
||||||
|
aiRequestsResult.status === "fulfilled"
|
||||||
|
? aiRequestsResult.value
|
||||||
|
: "error",
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
nodeEnv: process.env.NODE_ENV,
|
||||||
|
enhancedPooling: process.env.USE_ENHANCED_POOLING === "true",
|
||||||
|
connectionLimit: process.env.DATABASE_CONNECTION_LIMIT || "default",
|
||||||
|
poolTimeout: process.env.DATABASE_POOL_TIMEOUT || "default",
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Database health check failed:", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: "error",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
app/api/admin/refresh-sessions/route.ts
Normal file
146
app/api/admin/refresh-sessions/route.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { fetchAndParseCsv } from "../../../../lib/csvFetcher";
|
||||||
|
import { processQueuedImports } from "../../../../lib/importProcessor";
|
||||||
|
import { prisma } from "../../../../lib/prisma";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
let { companyId } = body;
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
// Try to get user from prisma based on session cookie
|
||||||
|
try {
|
||||||
|
const session = await prisma.session.findFirst({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
where: {
|
||||||
|
/* Add session check criteria here */
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
companyId = session.companyId;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log error for server-side debugging
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
// Use a server-side logging approach instead of console
|
||||||
|
process.stderr.write(`Error fetching session: ${errorMessage}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Company ID is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await prisma.company.findUnique({
|
||||||
|
where: { id: companyId },
|
||||||
|
});
|
||||||
|
if (!company) {
|
||||||
|
return NextResponse.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSessionData = await fetchAndParseCsv(
|
||||||
|
company.csvUrl,
|
||||||
|
company.csvUsername as string | undefined,
|
||||||
|
company.csvPassword as string | undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
let importedCount = 0;
|
||||||
|
|
||||||
|
// Create SessionImport records for new data
|
||||||
|
for (const rawSession of rawSessionData) {
|
||||||
|
try {
|
||||||
|
// Use upsert to handle duplicates gracefully
|
||||||
|
await prisma.sessionImport.upsert({
|
||||||
|
where: {
|
||||||
|
companyId_externalSessionId: {
|
||||||
|
companyId: company.id,
|
||||||
|
externalSessionId: rawSession.externalSessionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
// Update existing record with latest data
|
||||||
|
startTimeRaw: rawSession.startTimeRaw,
|
||||||
|
endTimeRaw: rawSession.endTimeRaw,
|
||||||
|
ipAddress: rawSession.ipAddress,
|
||||||
|
countryCode: rawSession.countryCode,
|
||||||
|
language: rawSession.language,
|
||||||
|
messagesSent: rawSession.messagesSent,
|
||||||
|
sentimentRaw: rawSession.sentimentRaw,
|
||||||
|
escalatedRaw: rawSession.escalatedRaw,
|
||||||
|
forwardedHrRaw: rawSession.forwardedHrRaw,
|
||||||
|
fullTranscriptUrl: rawSession.fullTranscriptUrl,
|
||||||
|
avgResponseTimeSeconds: rawSession.avgResponseTimeSeconds,
|
||||||
|
tokens: rawSession.tokens,
|
||||||
|
tokensEur: rawSession.tokensEur,
|
||||||
|
category: rawSession.category,
|
||||||
|
initialMessage: rawSession.initialMessage,
|
||||||
|
// Status tracking now handled by ProcessingStatusManager
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
companyId: company.id,
|
||||||
|
externalSessionId: rawSession.externalSessionId,
|
||||||
|
startTimeRaw: rawSession.startTimeRaw,
|
||||||
|
endTimeRaw: rawSession.endTimeRaw,
|
||||||
|
ipAddress: rawSession.ipAddress,
|
||||||
|
countryCode: rawSession.countryCode,
|
||||||
|
language: rawSession.language,
|
||||||
|
messagesSent: rawSession.messagesSent,
|
||||||
|
sentimentRaw: rawSession.sentimentRaw,
|
||||||
|
escalatedRaw: rawSession.escalatedRaw,
|
||||||
|
forwardedHrRaw: rawSession.forwardedHrRaw,
|
||||||
|
fullTranscriptUrl: rawSession.fullTranscriptUrl,
|
||||||
|
avgResponseTimeSeconds: rawSession.avgResponseTimeSeconds,
|
||||||
|
tokens: rawSession.tokens,
|
||||||
|
tokensEur: rawSession.tokensEur,
|
||||||
|
category: rawSession.category,
|
||||||
|
initialMessage: rawSession.initialMessage,
|
||||||
|
// Status tracking now handled by ProcessingStatusManager
|
||||||
|
},
|
||||||
|
});
|
||||||
|
importedCount++;
|
||||||
|
} catch (error) {
|
||||||
|
// Log individual session import errors but continue processing
|
||||||
|
process.stderr.write(
|
||||||
|
`Failed to import session ${rawSession.externalSessionId}: ${error}\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediately process the queued imports to create Session records
|
||||||
|
console.log("[Refresh API] Processing queued imports...");
|
||||||
|
await processQueuedImports(100); // Process up to 100 imports immediately
|
||||||
|
|
||||||
|
// Count how many sessions were created
|
||||||
|
const sessionCount = await prisma.session.count({
|
||||||
|
where: { companyId: company.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
imported: importedCount,
|
||||||
|
total: rawSessionData.length,
|
||||||
|
sessions: sessionCount,
|
||||||
|
message: `Successfully imported ${importedCount} records and processed them into sessions. Total sessions: ${sessionCount}`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const error = e instanceof Error ? e.message : "An unknown error occurred";
|
||||||
|
return NextResponse.json({ error }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/api/admin/trigger-processing/route.ts
Normal file
126
app/api/admin/trigger-processing/route.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
interface SessionUser {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionData {
|
||||||
|
user: SessionUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = (await getServerSession(authOptions)) as SessionData | null;
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: session.user.email },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
companyId: true,
|
||||||
|
company: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "No user found" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has ADMIN role
|
||||||
|
if (user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Admin access required" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get optional parameters from request body
|
||||||
|
const body = await request.json();
|
||||||
|
const { batchSize, maxConcurrency } = body;
|
||||||
|
|
||||||
|
// Validate parameters
|
||||||
|
const validatedBatchSize =
|
||||||
|
batchSize && batchSize > 0 ? Number.parseInt(batchSize) : null;
|
||||||
|
const validatedMaxConcurrency =
|
||||||
|
maxConcurrency && maxConcurrency > 0
|
||||||
|
? Number.parseInt(maxConcurrency)
|
||||||
|
: 5;
|
||||||
|
|
||||||
|
// Check how many sessions need AI processing using the new status system
|
||||||
|
const sessionsNeedingAI =
|
||||||
|
await ProcessingStatusManager.getSessionsNeedingProcessing(
|
||||||
|
ProcessingStage.AI_ANALYSIS,
|
||||||
|
1000 // Get count only
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter to sessions for this company
|
||||||
|
const companySessionsNeedingAI = sessionsNeedingAI.filter(
|
||||||
|
(statusRecord) => statusRecord.session.companyId === user.companyId
|
||||||
|
);
|
||||||
|
|
||||||
|
const unprocessedCount = companySessionsNeedingAI.length;
|
||||||
|
|
||||||
|
if (unprocessedCount === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "No sessions requiring AI processing found",
|
||||||
|
unprocessedCount: 0,
|
||||||
|
processedCount: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start processing (this will run asynchronously)
|
||||||
|
const _startTime = Date.now();
|
||||||
|
|
||||||
|
// Note: We're calling the function but not awaiting it to avoid timeout
|
||||||
|
// The processing will continue in the background
|
||||||
|
processUnprocessedSessions(validatedBatchSize, validatedMaxConcurrency)
|
||||||
|
.then(() => {
|
||||||
|
console.log(
|
||||||
|
`[Manual Trigger] Processing completed for company ${user.companyId}`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(
|
||||||
|
`[Manual Trigger] Processing failed for company ${user.companyId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Started processing ${unprocessedCount} unprocessed sessions`,
|
||||||
|
unprocessedCount,
|
||||||
|
batchSize: validatedBatchSize || unprocessedCount,
|
||||||
|
maxConcurrency: validatedMaxConcurrency,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Manual Trigger] Error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Failed to trigger processing",
|
||||||
|
details: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,115 +1,6 @@
|
|||||||
import NextAuth, { NextAuthConfig } from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import { D1Adapter } from "@auth/d1-adapter";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
|
||||||
import * as bcrypt from "bcryptjs";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { PrismaD1 } from "@prisma/adapter-d1";
|
|
||||||
|
|
||||||
// Check if we're in a Cloudflare Workers environment
|
const handler = NextAuth(authOptions);
|
||||||
const isCloudflareWorker =
|
|
||||||
typeof globalThis.caches !== "undefined" &&
|
|
||||||
typeof (globalThis as any).WebSocketPair !== "undefined";
|
|
||||||
|
|
||||||
const config: NextAuthConfig = {
|
export { handler as GET, handler as POST };
|
||||||
providers: [
|
|
||||||
Credentials({
|
|
||||||
name: "credentials",
|
|
||||||
credentials: {
|
|
||||||
email: { label: "Email", type: "email" },
|
|
||||||
password: { label: "Password", type: "password" },
|
|
||||||
},
|
|
||||||
authorize: async (credentials) => {
|
|
||||||
if (!credentials?.email || !credentials?.password) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
|
|
||||||
// Initialize Prisma based on environment
|
|
||||||
if (isCloudflareWorker) {
|
|
||||||
// In Cloudflare Workers, get DB from bindings
|
|
||||||
const adapter = new PrismaD1((globalThis as any).DB);
|
|
||||||
prisma = new PrismaClient({ adapter });
|
|
||||||
} else {
|
|
||||||
// In local development, use standard Prisma
|
|
||||||
prisma = new PrismaClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: credentials.email as string },
|
|
||||||
include: { company: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await bcrypt.compare(
|
|
||||||
credentials.password as string,
|
|
||||||
user.password
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!valid) {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.email, // Use email as name
|
|
||||||
role: user.role,
|
|
||||||
companyId: user.companyId,
|
|
||||||
company: user.company.name,
|
|
||||||
};
|
|
||||||
|
|
||||||
await prisma.$disconnect();
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Authentication error:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
callbacks: {
|
|
||||||
jwt: async ({ token, user }: any) => {
|
|
||||||
if (user) {
|
|
||||||
token.role = user.role;
|
|
||||||
token.companyId = user.companyId;
|
|
||||||
token.company = user.company;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
session: async ({ session, token }: any) => {
|
|
||||||
if (token && session.user) {
|
|
||||||
session.user.id = token.sub;
|
|
||||||
session.user.role = token.role;
|
|
||||||
session.user.companyId = token.companyId;
|
|
||||||
session.user.company = token.company;
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pages: {
|
|
||||||
signIn: "/login",
|
|
||||||
error: "/login",
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
strategy: "jwt",
|
|
||||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
||||||
},
|
|
||||||
secret: process.env.AUTH_SECRET,
|
|
||||||
trustHost: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add D1 adapter only in Cloudflare Workers environment
|
|
||||||
if (isCloudflareWorker && (globalThis as any).DB) {
|
|
||||||
(config as any).adapter = D1Adapter((globalThis as any).DB);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { handlers } = NextAuth(config);
|
|
||||||
|
|
||||||
export const { GET, POST } = handlers;
|
|
||||||
|
|||||||
51
app/api/dashboard/config/route.ts
Normal file
51
app/api/dashboard/config/route.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "../../../../lib/auth";
|
||||||
|
import { prisma } from "../../../../lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: session.user.email as string },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "No user" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get company data
|
||||||
|
const company = await prisma.company.findUnique({
|
||||||
|
where: { id: user.companyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ company });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: session.user.email as string },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "No user" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { csvUrl } = body;
|
||||||
|
|
||||||
|
await prisma.company.update({
|
||||||
|
where: { id: user.companyId },
|
||||||
|
data: { csvUrl },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
176
app/api/dashboard/metrics/route.ts
Normal file
176
app/api/dashboard/metrics/route.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "../../../../lib/auth";
|
||||||
|
import { sessionMetrics } from "../../../../lib/metrics";
|
||||||
|
import { prisma } from "../../../../lib/prisma";
|
||||||
|
import type { ChatSession } from "../../../../lib/types";
|
||||||
|
|
||||||
|
interface SessionUser {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionData {
|
||||||
|
user: SessionUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = (await getServerSession(authOptions)) as SessionData | null;
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: session.user.email },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
companyId: true,
|
||||||
|
company: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
csvUrl: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "No user" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get date range from query parameters
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const startDate = searchParams.get("startDate");
|
||||||
|
const endDate = searchParams.get("endDate");
|
||||||
|
|
||||||
|
// Build where clause with optional date filtering
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch sessions without messages first for better performance
|
||||||
|
const prismaSessions = await prisma.session.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
companyId: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
createdAt: true,
|
||||||
|
category: true,
|
||||||
|
language: true,
|
||||||
|
country: true,
|
||||||
|
ipAddress: true,
|
||||||
|
sentiment: true,
|
||||||
|
messagesSent: true,
|
||||||
|
avgResponseTime: true,
|
||||||
|
escalated: true,
|
||||||
|
forwardedHr: true,
|
||||||
|
initialMsg: true,
|
||||||
|
fullTranscriptUrl: true,
|
||||||
|
summary: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch fetch questions for all sessions at once if needed for metrics
|
||||||
|
const sessionIds = prismaSessions.map((s) => s.id);
|
||||||
|
const sessionQuestions = await prisma.sessionQuestion.findMany({
|
||||||
|
where: { sessionId: { in: sessionIds } },
|
||||||
|
include: { question: true },
|
||||||
|
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[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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}`,
|
||||||
|
sessionId: ps.id,
|
||||||
|
timestamp: ps.createdAt,
|
||||||
|
role: "User",
|
||||||
|
content: q,
|
||||||
|
order: index,
|
||||||
|
createdAt: ps.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: ps.id,
|
||||||
|
sessionId: ps.id,
|
||||||
|
companyId: ps.companyId,
|
||||||
|
startTime: new Date(ps.startTime),
|
||||||
|
endTime: ps.endTime ? new Date(ps.endTime) : null,
|
||||||
|
transcriptContent: "",
|
||||||
|
createdAt: new Date(ps.createdAt),
|
||||||
|
updatedAt: new Date(ps.createdAt),
|
||||||
|
category: ps.category || undefined,
|
||||||
|
language: ps.language || undefined,
|
||||||
|
country: ps.country || undefined,
|
||||||
|
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,
|
||||||
|
escalated: ps.escalated || false,
|
||||||
|
forwardedHr: ps.forwardedHr || false,
|
||||||
|
initialMsg: ps.initialMsg || undefined,
|
||||||
|
fullTranscriptUrl: ps.fullTranscriptUrl || undefined,
|
||||||
|
summary: ps.summary || undefined,
|
||||||
|
messages: mockMessages, // Use questions as messages for metrics
|
||||||
|
userId: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pass company config to metrics
|
||||||
|
const companyConfigForMetrics = {
|
||||||
|
// Add company-specific configuration here in the future
|
||||||
|
};
|
||||||
|
|
||||||
|
const metrics = sessionMetrics(chatSessions, companyConfigForMetrics);
|
||||||
|
|
||||||
|
// Calculate date range from sessions
|
||||||
|
let dateRange: { minDate: string; maxDate: string } | null = null;
|
||||||
|
if (prismaSessions.length > 0) {
|
||||||
|
const dates = prismaSessions
|
||||||
|
.map((s) => new Date(s.startTime))
|
||||||
|
.sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
dateRange = {
|
||||||
|
minDate: dates[0].toISOString().split("T")[0], // First session date
|
||||||
|
maxDate: dates[dates.length - 1].toISOString().split("T")[0], // Last session date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
metrics,
|
||||||
|
csvUrl: user.company.csvUrl,
|
||||||
|
company: user.company,
|
||||||
|
dateRange,
|
||||||
|
});
|
||||||
|
}
|
||||||
63
app/api/dashboard/session-filter-options/route.ts
Normal file
63
app/api/dashboard/session-filter-options/route.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "../../../../lib/auth";
|
||||||
|
import { prisma } from "../../../../lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest) {
|
||||||
|
const authSession = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!authSession || !authSession.user?.companyId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = authSession.user.companyId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use groupBy for better performance with distinct values
|
||||||
|
const [categoryGroups, languageGroups] = await Promise.all([
|
||||||
|
prisma.session.groupBy({
|
||||||
|
by: ["category"],
|
||||||
|
where: {
|
||||||
|
companyId,
|
||||||
|
category: { not: null },
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
category: "asc",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.session.groupBy({
|
||||||
|
by: ["language"],
|
||||||
|
where: {
|
||||||
|
companyId,
|
||||||
|
language: { not: null },
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
language: "asc",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const distinctCategories = categoryGroups
|
||||||
|
.map((g) => g.category)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
const distinctLanguages = languageGroups
|
||||||
|
.map((g) => g.language)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
categories: distinctCategories,
|
||||||
|
languages: distinctLanguages,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "An unknown error occurred";
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Failed to fetch filter options",
|
||||||
|
details: errorMessage,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,28 +1,32 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../../lib/prisma";
|
||||||
import { ChatSession } from "../../../../lib/types";
|
import type { ChatSession } from "../../../../../lib/types";
|
||||||
|
|
||||||
export default async function handler(
|
export async function GET(
|
||||||
req: NextApiRequest,
|
_request: NextRequest,
|
||||||
res: NextApiResponse
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
if (req.method !== "GET") {
|
const { id } = await params;
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = req.query;
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
if (!id || typeof id !== "string") {
|
{ error: "Session ID is required" },
|
||||||
return res.status(400).json({ error: "Session ID is required" });
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prismaSession = await prisma.session.findUnique({
|
const prismaSession = await prisma.session.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
include: {
|
||||||
|
messages: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!prismaSession) {
|
if (!prismaSession) {
|
||||||
return res.status(404).json({ error: "Session not found" });
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map Prisma session object to ChatSession type
|
// Map Prisma session object to ChatSession type
|
||||||
@ -50,19 +54,29 @@ export default async function handler(
|
|||||||
avgResponseTime: prismaSession.avgResponseTime ?? null,
|
avgResponseTime: prismaSession.avgResponseTime ?? null,
|
||||||
escalated: prismaSession.escalated ?? undefined,
|
escalated: prismaSession.escalated ?? undefined,
|
||||||
forwardedHr: prismaSession.forwardedHr ?? undefined,
|
forwardedHr: prismaSession.forwardedHr ?? undefined,
|
||||||
tokens: prismaSession.tokens ?? undefined,
|
|
||||||
tokensEur: prismaSession.tokensEur ?? undefined,
|
|
||||||
initialMsg: prismaSession.initialMsg ?? undefined,
|
initialMsg: prismaSession.initialMsg ?? undefined,
|
||||||
fullTranscriptUrl: prismaSession.fullTranscriptUrl ?? null,
|
fullTranscriptUrl: prismaSession.fullTranscriptUrl ?? null,
|
||||||
transcriptContent: prismaSession.transcriptContent ?? null,
|
summary: prismaSession.summary ?? null, // New field
|
||||||
|
transcriptContent: null, // Not available in Session model
|
||||||
|
messages:
|
||||||
|
prismaSession.messages?.map((msg) => ({
|
||||||
|
id: msg.id,
|
||||||
|
sessionId: msg.sessionId,
|
||||||
|
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
order: msg.order,
|
||||||
|
createdAt: new Date(msg.createdAt),
|
||||||
|
})) ?? [], // New field - parsed messages
|
||||||
};
|
};
|
||||||
|
|
||||||
return res.status(200).json({ session });
|
return NextResponse.json({ session });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : "An unknown error occurred";
|
error instanceof Error ? error.message : "An unknown error occurred";
|
||||||
return res
|
return NextResponse.json(
|
||||||
.status(500)
|
{ error: "Failed to fetch session", details: errorMessage },
|
||||||
.json({ error: "Failed to fetch session", details: errorMessage });
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,39 +1,29 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { getApiSession } from "../../../lib/api-auth";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { getServerSession } from "next-auth/next";
|
||||||
import {
|
import { authOptions } from "../../../../lib/auth";
|
||||||
ChatSession,
|
import { prisma } from "../../../../lib/prisma";
|
||||||
SessionApiResponse,
|
import type { ChatSession } from "../../../../lib/types";
|
||||||
SessionQuery,
|
|
||||||
} from "../../../lib/types";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
|
|
||||||
export default async function handler(
|
export async function GET(request: NextRequest) {
|
||||||
req: NextApiRequest,
|
const authSession = await getServerSession(authOptions);
|
||||||
res: NextApiResponse<SessionApiResponse | { error: string; details?: string }>
|
|
||||||
) {
|
|
||||||
if (req.method !== "GET") {
|
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const authSession = await getApiSession(req, res);
|
|
||||||
|
|
||||||
if (!authSession || !authSession.user?.companyId) {
|
if (!authSession || !authSession.user?.companyId) {
|
||||||
return res.status(401).json({ error: "Unauthorized" });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyId = authSession.user.companyId;
|
const companyId = authSession.user.companyId;
|
||||||
const {
|
const { searchParams } = new URL(request.url);
|
||||||
searchTerm,
|
|
||||||
category,
|
const searchTerm = searchParams.get("searchTerm");
|
||||||
language,
|
const category = searchParams.get("category");
|
||||||
startDate,
|
const language = searchParams.get("language");
|
||||||
endDate,
|
const startDate = searchParams.get("startDate");
|
||||||
sortKey,
|
const endDate = searchParams.get("endDate");
|
||||||
sortOrder,
|
const sortKey = searchParams.get("sortKey");
|
||||||
page: queryPage,
|
const sortOrder = searchParams.get("sortOrder");
|
||||||
pageSize: queryPageSize,
|
const queryPage = searchParams.get("page");
|
||||||
} = req.query as SessionQuery;
|
const queryPageSize = searchParams.get("pageSize");
|
||||||
|
|
||||||
const page = Number(queryPage) || 1;
|
const page = Number(queryPage) || 1;
|
||||||
const pageSize = Number(queryPageSize) || 10;
|
const pageSize = Number(queryPageSize) || 10;
|
||||||
@ -42,38 +32,34 @@ export default async function handler(
|
|||||||
const whereClause: Prisma.SessionWhereInput = { companyId };
|
const whereClause: Prisma.SessionWhereInput = { companyId };
|
||||||
|
|
||||||
// Search Term
|
// Search Term
|
||||||
if (
|
if (searchTerm && searchTerm.trim() !== "") {
|
||||||
searchTerm &&
|
|
||||||
typeof searchTerm === "string" &&
|
|
||||||
searchTerm.trim() !== ""
|
|
||||||
) {
|
|
||||||
const searchConditions = [
|
const searchConditions = [
|
||||||
{ id: { contains: searchTerm } },
|
{ id: { contains: searchTerm } },
|
||||||
{ category: { contains: searchTerm } },
|
|
||||||
{ initialMsg: { contains: searchTerm } },
|
{ initialMsg: { contains: searchTerm } },
|
||||||
{ transcriptContent: { contains: searchTerm } },
|
{ summary: { contains: searchTerm } },
|
||||||
];
|
];
|
||||||
whereClause.OR = searchConditions;
|
whereClause.OR = searchConditions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category Filter
|
// Category Filter
|
||||||
if (category && typeof category === "string" && category.trim() !== "") {
|
if (category && category.trim() !== "") {
|
||||||
|
// Cast to SessionCategory enum if it's a valid value
|
||||||
whereClause.category = category;
|
whereClause.category = category;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Language Filter
|
// Language Filter
|
||||||
if (language && typeof language === "string" && language.trim() !== "") {
|
if (language && language.trim() !== "") {
|
||||||
whereClause.language = language;
|
whereClause.language = language;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date Range Filter
|
// Date Range Filter
|
||||||
if (startDate && typeof startDate === "string") {
|
if (startDate) {
|
||||||
whereClause.startTime = {
|
whereClause.startTime = {
|
||||||
...((whereClause.startTime as object) || {}),
|
...((whereClause.startTime as object) || {}),
|
||||||
gte: new Date(startDate),
|
gte: new Date(startDate),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (endDate && typeof endDate === "string") {
|
if (endDate) {
|
||||||
const inclusiveEndDate = new Date(endDate);
|
const inclusiveEndDate = new Date(endDate);
|
||||||
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
|
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
|
||||||
whereClause.startTime = {
|
whereClause.startTime = {
|
||||||
@ -97,9 +83,7 @@ export default async function handler(
|
|||||||
| Prisma.SessionOrderByWithRelationInput[];
|
| Prisma.SessionOrderByWithRelationInput[];
|
||||||
|
|
||||||
const primarySortField =
|
const primarySortField =
|
||||||
sortKey && typeof sortKey === "string" && validSortKeys[sortKey]
|
sortKey && validSortKeys[sortKey] ? validSortKeys[sortKey] : "startTime"; // Default to startTime field if sortKey is invalid/missing
|
||||||
? validSortKeys[sortKey]
|
|
||||||
: "startTime"; // Default to startTime field if sortKey is invalid/missing
|
|
||||||
|
|
||||||
const primarySortOrder =
|
const primarySortOrder =
|
||||||
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order
|
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order
|
||||||
@ -114,9 +98,6 @@ export default async function handler(
|
|||||||
{ startTime: "desc" },
|
{ startTime: "desc" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// Note: If sortKey was initially undefined or invalid, primarySortField defaults to "startTime",
|
|
||||||
// and primarySortOrder defaults to "desc". This makes orderByCondition = { startTime: "desc" },
|
|
||||||
// which is the correct overall default sort.
|
|
||||||
|
|
||||||
const prismaSessions = await prisma.session.findMany({
|
const prismaSessions = await prisma.session.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
@ -145,19 +126,18 @@ export default async function handler(
|
|||||||
avgResponseTime: ps.avgResponseTime ?? null,
|
avgResponseTime: ps.avgResponseTime ?? null,
|
||||||
escalated: ps.escalated ?? undefined,
|
escalated: ps.escalated ?? undefined,
|
||||||
forwardedHr: ps.forwardedHr ?? undefined,
|
forwardedHr: ps.forwardedHr ?? undefined,
|
||||||
tokens: ps.tokens ?? undefined,
|
|
||||||
tokensEur: ps.tokensEur ?? undefined,
|
|
||||||
initialMsg: ps.initialMsg ?? undefined,
|
initialMsg: ps.initialMsg ?? undefined,
|
||||||
fullTranscriptUrl: ps.fullTranscriptUrl ?? null,
|
fullTranscriptUrl: ps.fullTranscriptUrl ?? null,
|
||||||
transcriptContent: ps.transcriptContent ?? null,
|
transcriptContent: null, // Transcript content is now fetched from fullTranscriptUrl when needed
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return res.status(200).json({ sessions, totalSessions });
|
return NextResponse.json({ sessions, totalSessions });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : "An unknown error occurred";
|
error instanceof Error ? error.message : "An unknown error occurred";
|
||||||
return res
|
return NextResponse.json(
|
||||||
.status(500)
|
{ error: "Failed to fetch sessions", details: errorMessage },
|
||||||
.json({ error: "Failed to fetch sessions", details: errorMessage });
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
34
app/api/dashboard/settings/route.ts
Normal file
34
app/api/dashboard/settings/route.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "../../../../lib/auth";
|
||||||
|
import { prisma } from "../../../../lib/prisma";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user || session.user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: session.user.email as string },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "No user" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { csvUrl, csvUsername, csvPassword } = body;
|
||||||
|
|
||||||
|
await prisma.company.update({
|
||||||
|
where: { id: user.companyId },
|
||||||
|
data: {
|
||||||
|
csvUrl,
|
||||||
|
csvUsername,
|
||||||
|
...(csvPassword ? { csvPassword } : {}),
|
||||||
|
// Remove sentimentAlert field - not in current schema
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
80
app/api/dashboard/users/route.ts
Normal file
80
app/api/dashboard/users/route.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
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;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: session.user.email as string },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "No user" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { companyId: user.companyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mappedUsers: UserBasicInfo[] = users.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
email: u.email,
|
||||||
|
role: u.role,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ users: mappedUsers });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user || session.user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: session.user.email as string },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "No user" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { email, role } = body;
|
||||||
|
|
||||||
|
if (!email || !role) {
|
||||||
|
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (exists) {
|
||||||
|
return NextResponse.json({ error: "Email exists" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPassword = crypto.randomBytes(12).toString("base64").slice(0, 12); // secure random initial password
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
password: await bcrypt.hash(tempPassword, 10),
|
||||||
|
companyId: user.companyId,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Email user their temp password (stub, for demo) - Implement a robust and secure email sending mechanism. Consider using a transactional email service.
|
||||||
|
return NextResponse.json({ ok: true, tempPassword });
|
||||||
|
}
|
||||||
94
app/api/forgot-password/route.ts
Normal file
94
app/api/forgot-password/route.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// In-memory rate limiting for password reset requests
|
||||||
|
const resetAttempts = new Map<string, { count: number; resetTime: number }>();
|
||||||
|
|
||||||
|
function checkRateLimit(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const attempts = resetAttempts.get(ip);
|
||||||
|
|
||||||
|
if (!attempts || now > attempts.resetTime) {
|
||||||
|
resetAttempts.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 }); // 15 minute window
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts.count >= 5) {
|
||||||
|
// Max 5 reset requests per 15 minutes per IP
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
if (!checkRateLimit(ip)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Too many password reset attempts. Please try again later.",
|
||||||
|
},
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
const validation = validateInput(forgotPasswordSchema, body);
|
||||||
|
if (!validation.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Invalid email format",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email } = validation.data;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
|
||||||
|
// Always return success for privacy (don't reveal if email exists)
|
||||||
|
// But only send email if user exists
|
||||||
|
if (user) {
|
||||||
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
||||||
|
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { email },
|
||||||
|
data: { resetToken: tokenHash, resetTokenExpiry: expiry },
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
|
||||||
|
await sendEmail(
|
||||||
|
email,
|
||||||
|
"Password Reset",
|
||||||
|
`Reset your password: ${resetUrl}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Forgot password error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Internal server error",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/api/platform/auth/[...nextauth]/route.ts
Normal file
6
app/api/platform/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { platformAuthOptions } from "../../../../../lib/platform-auth";
|
||||||
|
|
||||||
|
const handler = NextAuth(platformAuthOptions);
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
163
app/api/platform/companies/[id]/route.ts
Normal file
163
app/api/platform/companies/[id]/route.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
interface PlatformSession {
|
||||||
|
user: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
isPlatformUser?: boolean;
|
||||||
|
platformRole?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)) as PlatformSession | null;
|
||||||
|
|
||||||
|
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,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
invitedBy: true,
|
||||||
|
invitedAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
sessions: true,
|
||||||
|
imports: 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, email, maxUsers, csvUrl, csvUsername, csvPassword, status } =
|
||||||
|
body;
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
app/api/platform/companies/[id]/users/route.ts
Normal file
151
app/api/platform/companies/[id]/users/route.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// POST /api/platform/companies/[id]/users - Invite user to company
|
||||||
|
export async function POST(
|
||||||
|
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: companyId } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, email, role = "USER" } = body;
|
||||||
|
|
||||||
|
if (!name || !email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Name and email are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if company exists
|
||||||
|
const company = await prisma.company.findUnique({
|
||||||
|
where: { id: companyId },
|
||||||
|
include: { _count: { select: { users: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
return NextResponse.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user limit would be exceeded
|
||||||
|
if (company._count.users >= company.maxUsers) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Company has reached maximum user limit" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already exists in this company
|
||||||
|
const existingUser = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
companyId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "User already exists in this company" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a temporary password (in a real app, you'd send an invitation email)
|
||||||
|
const tempPassword = `temp${Math.random().toString(36).slice(-8)}`;
|
||||||
|
const hashedPassword = await hash(tempPassword, 10);
|
||||||
|
|
||||||
|
// Create the user
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
role,
|
||||||
|
companyId,
|
||||||
|
invitedBy: session.user.email,
|
||||||
|
invitedAt: new Date(),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
invitedBy: true,
|
||||||
|
invitedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a real application, you would send an email with login credentials
|
||||||
|
// For now, we'll return the temporary password
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Platform user invitation error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/platform/companies/[id]/users - Get company users
|
||||||
|
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: companyId } = await params;
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { companyId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
invitedBy: true,
|
||||||
|
invitedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ users });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Platform users list error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
app/api/platform/companies/route.ts
Normal file
187
app/api/platform/companies/route.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// 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 = Number.parseInt(searchParams.get("page") || "1");
|
||||||
|
const limit = Number.parseInt(searchParams.get("limit") || "20");
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: {
|
||||||
|
status?: CompanyStatus;
|
||||||
|
name?: {
|
||||||
|
contains: string;
|
||||||
|
mode: "insensitive";
|
||||||
|
};
|
||||||
|
} = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (search) {
|
||||||
|
where.name = {
|
||||||
|
contains: search,
|
||||||
|
mode: "insensitive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [companies, total] = await Promise.all([
|
||||||
|
prisma.company.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
maxUsers: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
sessions: true,
|
||||||
|
imports: true,
|
||||||
|
users: 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,
|
||||||
|
adminEmail,
|
||||||
|
adminName,
|
||||||
|
adminPassword,
|
||||||
|
maxUsers = 10,
|
||||||
|
status = "TRIAL",
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!name || !csvUrl) {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate password if not provided
|
||||||
|
const finalAdminPassword =
|
||||||
|
adminPassword || `Temp${Math.random().toString(36).slice(2, 8)}!`;
|
||||||
|
|
||||||
|
// Hash the admin password
|
||||||
|
const bcrypt = await import("bcryptjs");
|
||||||
|
const hashedPassword = await bcrypt.hash(finalAdminPassword, 12);
|
||||||
|
|
||||||
|
// Create company and admin user in a transaction
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Create the company
|
||||||
|
const company = await tx.company.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
csvUrl,
|
||||||
|
csvUsername: csvUsername || null,
|
||||||
|
csvPassword: csvPassword || null,
|
||||||
|
maxUsers,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the admin user
|
||||||
|
const adminUser = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
email: adminEmail,
|
||||||
|
password: hashedPassword,
|
||||||
|
name: adminName,
|
||||||
|
role: "ADMIN",
|
||||||
|
companyId: company.id,
|
||||||
|
invitedBy: session.user.email || "platform",
|
||||||
|
invitedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
generatedPassword: result.generatedPassword,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Platform company creation error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
app/api/register/route.ts
Normal file
136
app/api/register/route.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
import { registerSchema, validateInput } from "../../../lib/validation";
|
||||||
|
|
||||||
|
// In-memory rate limiting (for production, use Redis or similar)
|
||||||
|
const registrationAttempts = new Map<
|
||||||
|
string,
|
||||||
|
{ count: number; resetTime: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
function checkRateLimit(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const attempts = registrationAttempts.get(ip);
|
||||||
|
|
||||||
|
if (!attempts || now > attempts.resetTime) {
|
||||||
|
registrationAttempts.set(ip, { count: 1, resetTime: now + 60 * 60 * 1000 }); // 1 hour window
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts.count >= 3) {
|
||||||
|
// Max 3 registrations per hour per IP
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting check
|
||||||
|
const ip =
|
||||||
|
request.ip || request.headers.get("x-forwarded-for") || "unknown";
|
||||||
|
if (!checkRateLimit(ip)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Too many registration attempts. Please try again later.",
|
||||||
|
},
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Validate input with Zod schema
|
||||||
|
const validation = validateInput(registerSchema, body);
|
||||||
|
if (!validation.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Validation failed",
|
||||||
|
details: validation.errors,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password, company } = validation.data;
|
||||||
|
|
||||||
|
// Check if email exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Email already exists",
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if company name already exists
|
||||||
|
const existingCompany = await prisma.company.findFirst({
|
||||||
|
where: { name: company },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCompany) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Company name already exists",
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create company and user in a transaction
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const newCompany = await tx.company.create({
|
||||||
|
data: {
|
||||||
|
name: company,
|
||||||
|
csvUrl: "", // Empty by default, can be set later in settings
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12); // Increased rounds for better security
|
||||||
|
|
||||||
|
const newUser = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
companyId: newCompany.id,
|
||||||
|
role: "USER", // Changed from ADMIN - users should be promoted by existing admins
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { company: newCompany, user: newUser };
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: "Registration successful",
|
||||||
|
userId: result.user.id,
|
||||||
|
companyId: result.company.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Registration error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Internal server error",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/api/reset-password/route.ts
Normal file
76
app/api/reset-password/route.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Validate input with strong password requirements
|
||||||
|
const validation = validateInput(resetPasswordSchema, body);
|
||||||
|
if (!validation.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Validation failed",
|
||||||
|
details: validation.errors,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, password } = validation.data;
|
||||||
|
|
||||||
|
// Hash the token to compare with stored hash
|
||||||
|
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
resetToken: tokenHash,
|
||||||
|
resetTokenExpiry: { gte: new Date() },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Invalid or expired token. Please request a new password reset.",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password with higher rounds for better security
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
password: hashedPassword,
|
||||||
|
resetToken: null,
|
||||||
|
resetTokenExpiry: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: "Password has been reset successfully.",
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Reset password error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "An internal server error occurred. Please try again later.",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,22 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { Database, Save, Settings, ShieldX } from "lucide-react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { Company } from "../../../lib/types";
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
interface CompanyConfigResponse {
|
import { Button } from "@/components/ui/button";
|
||||||
company: Company;
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
}
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { Company } from "../../../lib/types";
|
||||||
|
|
||||||
export default function CompanySettingsPage() {
|
export default function CompanySettingsPage() {
|
||||||
|
const csvUrlId = useId();
|
||||||
|
const csvUsernameId = useId();
|
||||||
|
const csvPasswordId = useId();
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
// We store the full company object for future use and updates after save operations
|
// 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
|
// 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 [csvUrl, setCsvUrl] = useState<string>("");
|
||||||
const [csvUsername, setCsvUsername] = useState<string>("");
|
const [csvUsername, setCsvUsername] = useState<string>("");
|
||||||
const [csvPassword, setCsvPassword] = useState<string>("");
|
const [csvPassword, setCsvPassword] = useState<string>("");
|
||||||
const [sentimentThreshold, setSentimentThreshold] = useState<string>("");
|
|
||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@ -26,11 +30,10 @@ export default function CompanySettingsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/dashboard/config");
|
const res = await fetch("/api/dashboard/config");
|
||||||
const data = (await res.json()) as CompanyConfigResponse;
|
const data = await res.json();
|
||||||
setCompany(data.company);
|
setCompany(data.company);
|
||||||
setCsvUrl(data.company.csvUrl || "");
|
setCsvUrl(data.company.csvUrl || "");
|
||||||
setCsvUsername(data.company.csvUsername || "");
|
setCsvUsername(data.company.csvUsername || "");
|
||||||
setSentimentThreshold(data.company.sentimentAlert?.toString() || "");
|
|
||||||
if (data.company.csvPassword) {
|
if (data.company.csvPassword) {
|
||||||
setCsvPassword(data.company.csvPassword);
|
setCsvPassword(data.company.csvPassword);
|
||||||
}
|
}
|
||||||
@ -55,17 +58,16 @@ export default function CompanySettingsPage() {
|
|||||||
csvUrl,
|
csvUrl,
|
||||||
csvUsername,
|
csvUsername,
|
||||||
csvPassword,
|
csvPassword,
|
||||||
sentimentThreshold,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setMessage("Settings saved successfully!");
|
setMessage("Settings saved successfully!");
|
||||||
// Update local state if needed
|
// Update local state if needed
|
||||||
const data = (await res.json()) as CompanyConfigResponse;
|
const data = await res.json();
|
||||||
setCompany(data.company);
|
setCompany(data.company);
|
||||||
} else {
|
} else {
|
||||||
const error = (await res.json()) as { message?: string };
|
const error = await res.json();
|
||||||
setMessage(
|
setMessage(
|
||||||
`Failed to save settings: ${error.message || "Unknown error"}`
|
`Failed to save settings: ${error.message || "Unknown error"}`
|
||||||
);
|
);
|
||||||
@ -78,49 +80,89 @@ export default function CompanySettingsPage() {
|
|||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-center py-10">Loading settings...</div>;
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Settings className="h-6 w-6" />
|
||||||
|
<CardTitle>Company Settings</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Loading settings...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for admin access
|
// Check for ADMIN access
|
||||||
if (session?.user?.role !== "admin") {
|
if (session?.user?.role !== "ADMIN") {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
<div className="space-y-6">
|
||||||
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
<Card>
|
||||||
<p>You don't have permission to view company settings.</p>
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ShieldX className="h-6 w-6 text-destructive" />
|
||||||
|
<CardTitle className="text-destructive">Access Denied</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
You don't have permission to view company settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
<Card>
|
||||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
<CardHeader>
|
||||||
Company Settings
|
<div className="flex items-center gap-3">
|
||||||
</h1>
|
<Settings className="h-6 w-6" />
|
||||||
|
<CardTitle>Company Settings</CardTitle>
|
||||||
{message && (
|
|
||||||
<div
|
|
||||||
className={`p-4 rounded mb-6 ${message.includes("Failed") ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{message && (
|
||||||
|
<Alert
|
||||||
|
variant={message.includes("Failed") ? "destructive" : "default"}
|
||||||
|
>
|
||||||
|
<AlertDescription>{message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="grid gap-6"
|
className="space-y-6"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSave();
|
handleSave();
|
||||||
}}
|
}}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
>
|
>
|
||||||
<div className="grid gap-2">
|
<Card>
|
||||||
<label className="font-medium text-gray-700">
|
<CardHeader>
|
||||||
CSV Data Source URL
|
<div className="flex items-center gap-2">
|
||||||
</label>
|
<Database className="h-5 w-5" />
|
||||||
<input
|
<CardTitle className="text-lg">
|
||||||
|
Data Source Configuration
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={csvUrlId}>CSV Data Source URL</Label>
|
||||||
|
<Input
|
||||||
|
id={csvUrlId}
|
||||||
type="text"
|
type="text"
|
||||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
|
||||||
value={csvUrl}
|
value={csvUrl}
|
||||||
onChange={(e) => setCsvUrl(e.target.value)}
|
onChange={(e) => setCsvUrl(e.target.value)}
|
||||||
placeholder="https://example.com/data.csv"
|
placeholder="https://example.com/data.csv"
|
||||||
@ -128,11 +170,11 @@ export default function CompanySettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="space-y-2">
|
||||||
<label className="font-medium text-gray-700">CSV Username</label>
|
<Label htmlFor={csvUsernameId}>CSV Username</Label>
|
||||||
<input
|
<Input
|
||||||
|
id={csvUsernameId}
|
||||||
type="text"
|
type="text"
|
||||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
|
||||||
value={csvUsername}
|
value={csvUsername}
|
||||||
onChange={(e) => setCsvUsername(e.target.value)}
|
onChange={(e) => setCsvUsername(e.target.value)}
|
||||||
placeholder="Username for CSV access (if needed)"
|
placeholder="Username for CSV access (if needed)"
|
||||||
@ -140,48 +182,32 @@ export default function CompanySettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="space-y-2">
|
||||||
<label className="font-medium text-gray-700">CSV Password</label>
|
<Label htmlFor={csvPasswordId}>CSV Password</Label>
|
||||||
<input
|
<Input
|
||||||
|
id={csvPasswordId}
|
||||||
type="password"
|
type="password"
|
||||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
|
||||||
value={csvPassword}
|
value={csvPassword}
|
||||||
onChange={(e) => setCsvPassword(e.target.value)}
|
onChange={(e) => setCsvPassword(e.target.value)}
|
||||||
placeholder="Password will be updated only if provided"
|
placeholder="Password will be updated only if provided"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-muted-foreground">
|
||||||
Leave blank to keep current password
|
Leave blank to keep current password
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="flex justify-end">
|
||||||
<label className="font-medium text-gray-700">
|
<Button type="submit" className="gap-2">
|
||||||
Sentiment Alert Threshold
|
<Save className="h-4 w-4" />
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
|
||||||
value={sentimentThreshold}
|
|
||||||
onChange={(e) => setSentimentThreshold(e.target.value)}
|
|
||||||
placeholder="Threshold value (0-100)"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Percentage of negative sentiment sessions to trigger alert (0-100)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="bg-sky-600 hover:bg-sky-700 text-white py-2 px-4 rounded-lg shadow transition-colors w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReactNode, useState, useEffect, useCallback } from "react";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useRouter } from "next/navigation";
|
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";
|
import Sidebar from "../../components/Sidebar";
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
|
const mainContentId = useId();
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -57,7 +58,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-100">
|
<div className="flex h-screen bg-background">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
isExpanded={isSidebarExpanded}
|
isExpanded={isSidebarExpanded}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@ -65,7 +66,8 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
onNavigate={collapseSidebar}
|
onNavigate={collapseSidebar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<main
|
||||||
|
id={mainContentId}
|
||||||
className={`flex-1 overflow-auto transition-all duration-300 py-4 pr-4
|
className={`flex-1 overflow-auto transition-all duration-300 py-4 pr-4
|
||||||
${
|
${
|
||||||
isSidebarExpanded
|
isSidebarExpanded
|
||||||
@ -76,7 +78,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
>
|
>
|
||||||
{/* <div className="w-full mx-auto">{children}</div> */}
|
{/* <div className="w-full mx-auto">{children}</div> */}
|
||||||
<div className="max-w-7xl mx-auto">{children}</div>
|
<div className="max-w-7xl mx-auto">{children}</div>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,71 +1,103 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { signOut, useSession } from "next-auth/react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
import {
|
||||||
SessionsLineChart,
|
CheckCircle,
|
||||||
CategoriesBarChart,
|
Clock,
|
||||||
LanguagePieChart,
|
Euro,
|
||||||
TokenUsageChart,
|
Globe,
|
||||||
} from "../../../components/Charts";
|
LogOut,
|
||||||
import { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
|
MessageCircle,
|
||||||
import MetricCard from "../../../components/MetricCard";
|
MessageSquare,
|
||||||
import DonutChart from "../../../components/DonutChart";
|
MoreVertical,
|
||||||
import WordCloud from "../../../components/WordCloud";
|
RefreshCw,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
import { useCallback, useEffect, useId, 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 {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
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 GeographicMap from "../../../components/GeographicMap";
|
||||||
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
|
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
|
||||||
import WelcomeBanner from "../../../components/WelcomeBanner";
|
import TopQuestionsChart from "../../../components/TopQuestionsChart";
|
||||||
|
import MetricCard from "../../../components/ui/metric-card";
|
||||||
interface MetricsApiResponse {
|
import WordCloud from "../../../components/WordCloud";
|
||||||
metrics: MetricsResult;
|
import type { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
|
||||||
company: Company;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safely wrapped component with useSession
|
// Safely wrapped component with useSession
|
||||||
function DashboardContent() {
|
function DashboardContent() {
|
||||||
const { data: session, status } = useSession(); // Add status from useSession
|
const { data: session, status } = useSession();
|
||||||
const router = useRouter(); // Initialize useRouter
|
const router = useRouter();
|
||||||
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
||||||
const [company, setCompany] = useState<Company | null>(null);
|
const [company, setCompany] = useState<Company | null>(null);
|
||||||
const [, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||||
|
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
||||||
|
|
||||||
const isAuditor = session?.user?.role === "auditor";
|
const refreshStatusId = useId();
|
||||||
|
const isAuditor = session?.user?.role === "AUDITOR";
|
||||||
|
|
||||||
|
// Function to fetch metrics with optional date range
|
||||||
|
const fetchMetrics = useCallback(
|
||||||
|
async (startDate?: string, endDate?: string, isInitial = false) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let url = "/api/dashboard/metrics";
|
||||||
|
if (startDate && endDate) {
|
||||||
|
url += `?startDate=${startDate}&endDate=${endDate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
setMetrics(data.metrics);
|
||||||
|
setCompany(data.company);
|
||||||
|
|
||||||
|
// Set initial load flag
|
||||||
|
if (isInitial) {
|
||||||
|
setIsInitialLoad(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching metrics:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Redirect if not authenticated
|
// Redirect if not authenticated
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
return; // Stop further execution in this effect
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch metrics and company on mount if authenticated
|
// Fetch metrics and company on mount if authenticated
|
||||||
if (status === "authenticated") {
|
if (status === "authenticated" && isInitialLoad) {
|
||||||
const fetchData = async () => {
|
fetchMetrics(undefined, undefined, true);
|
||||||
setLoading(true);
|
|
||||||
const res = await fetch("/api/dashboard/metrics");
|
|
||||||
const data = (await res.json()) as MetricsApiResponse;
|
|
||||||
console.log("Metrics from API:", {
|
|
||||||
avgSessionLength: data.metrics.avgSessionLength,
|
|
||||||
avgSessionTimeTrend: data.metrics.avgSessionTimeTrend,
|
|
||||||
totalSessionDuration: data.metrics.totalSessionDuration,
|
|
||||||
validSessionsForDuration: data.metrics.validSessionsForDuration,
|
|
||||||
});
|
|
||||||
setMetrics(data.metrics);
|
|
||||||
setCompany(data.company);
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
fetchData();
|
|
||||||
}
|
}
|
||||||
}, [status, router]); // Add status and router to dependency array
|
}, [status, router, isInitialLoad, fetchMetrics]);
|
||||||
|
|
||||||
async function handleRefresh() {
|
async function handleRefresh() {
|
||||||
if (isAuditor) return; // Prevent auditors from refreshing
|
if (isAuditor) return;
|
||||||
try {
|
try {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
|
|
||||||
// Make sure we have a company ID to send
|
|
||||||
if (!company?.id) {
|
if (!company?.id) {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
alert("Cannot refresh: Company ID is missing");
|
alert("Cannot refresh: Company ID is missing");
|
||||||
@ -79,12 +111,11 @@ function DashboardContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
// Refetch metrics
|
|
||||||
const metricsRes = await fetch("/api/dashboard/metrics");
|
const metricsRes = await fetch("/api/dashboard/metrics");
|
||||||
const data = (await metricsRes.json()) as MetricsApiResponse;
|
const data = await metricsRes.json();
|
||||||
setMetrics(data.metrics);
|
setMetrics(data.metrics);
|
||||||
} else {
|
} else {
|
||||||
const errorData = (await res.json()) as { error: string };
|
const errorData = await res.json();
|
||||||
alert(`Failed to refresh sessions: ${errorData.error}`);
|
alert(`Failed to refresh sessions: ${errorData.error}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -92,70 +123,167 @@ function DashboardContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate sentiment distribution
|
|
||||||
const getSentimentData = () => {
|
|
||||||
if (!metrics) return { positive: 0, neutral: 0, negative: 0 };
|
|
||||||
|
|
||||||
if (
|
|
||||||
metrics.sentimentPositiveCount !== undefined &&
|
|
||||||
metrics.sentimentNeutralCount !== undefined &&
|
|
||||||
metrics.sentimentNegativeCount !== undefined
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
positive: metrics.sentimentPositiveCount,
|
|
||||||
neutral: metrics.sentimentNeutralCount,
|
|
||||||
negative: metrics.sentimentNegativeCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = metrics.totalSessions || 1;
|
|
||||||
return {
|
|
||||||
positive: Math.round(total * 0.6),
|
|
||||||
neutral: Math.round(total * 0.3),
|
|
||||||
negative: Math.round(total * 0.1),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prepare token usage data
|
|
||||||
const getTokenData = () => {
|
|
||||||
if (!metrics || !metrics.tokensByDay) {
|
|
||||||
return { labels: [], values: [], costs: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = Object.keys(metrics.tokensByDay).sort();
|
|
||||||
const labels = days.slice(-7);
|
|
||||||
const values = labels.map((day) => metrics.tokensByDay?.[day] || 0);
|
|
||||||
const costs = labels.map((day) => metrics.tokensCostByDay?.[day] || 0);
|
|
||||||
|
|
||||||
return { labels, values, costs };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show loading state while session status is being determined
|
// Show loading state while session status is being determined
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return <div className="text-center py-10">Loading session...</div>;
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto" />
|
||||||
|
<p className="text-muted-foreground">Loading session...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If unauthenticated and not redirected yet (should be handled by useEffect, but as a fallback)
|
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
return <div className="text-center py-10">Redirecting to login...</div>;
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground">Redirecting to login...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metrics || !company) {
|
if (loading || !metrics || !company) {
|
||||||
return <div className="text-center py-10">Loading dashboard...</div>;
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header Skeleton */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-10 w-24" />
|
||||||
|
<Skeleton className="h-10 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Metrics Grid Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{Array.from({ length: 8 }, (_, i) => {
|
||||||
|
const metricTypes = [
|
||||||
|
"sessions",
|
||||||
|
"users",
|
||||||
|
"time",
|
||||||
|
"response",
|
||||||
|
"costs",
|
||||||
|
"peak",
|
||||||
|
"resolution",
|
||||||
|
"languages",
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<MetricCard
|
||||||
|
key={`skeleton-${metricTypes[i] || "metric"}-card-loading`}
|
||||||
|
title=""
|
||||||
|
value=""
|
||||||
|
isLoading
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to prepare word cloud data from metrics.wordCloudData
|
// Data preparation functions
|
||||||
|
const getSentimentData = () => {
|
||||||
|
if (!metrics) return [];
|
||||||
|
|
||||||
|
const sentimentData = {
|
||||||
|
positive: metrics.sentimentPositiveCount ?? 0,
|
||||||
|
neutral: metrics.sentimentNeutralCount ?? 0,
|
||||||
|
negative: metrics.sentimentNegativeCount ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "Positive",
|
||||||
|
value: sentimentData.positive,
|
||||||
|
color: "hsl(var(--chart-1))",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Neutral",
|
||||||
|
value: sentimentData.neutral,
|
||||||
|
color: "hsl(var(--chart-2))",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Negative",
|
||||||
|
value: sentimentData.negative,
|
||||||
|
color: "hsl(var(--chart-3))",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionsOverTimeData = () => {
|
||||||
|
if (!metrics?.days) return [];
|
||||||
|
|
||||||
|
return Object.entries(metrics.days).map(([date, value]) => ({
|
||||||
|
date: new Date(date).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}),
|
||||||
|
value: value as number,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoriesData = () => {
|
||||||
|
if (!metrics?.categories) return [];
|
||||||
|
|
||||||
|
return Object.entries(metrics.categories).map(([name, value]) => {
|
||||||
|
const formattedName = formatEnumValue(name) || name;
|
||||||
|
return {
|
||||||
|
name:
|
||||||
|
formattedName.length > 15
|
||||||
|
? `${formattedName.substring(0, 15)}...`
|
||||||
|
: formattedName,
|
||||||
|
value: value as number,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLanguagesData = () => {
|
||||||
|
if (!metrics?.languages) return [];
|
||||||
|
|
||||||
|
return Object.entries(metrics.languages).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value: value as number,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const getWordCloudData = (): WordCloudWord[] => {
|
const getWordCloudData = (): WordCloudWord[] => {
|
||||||
if (!metrics || !metrics.wordCloudData) return [];
|
if (!metrics?.wordCloudData) return [];
|
||||||
return metrics.wordCloudData;
|
return metrics.wordCloudData;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to prepare country data for the map using actual metrics
|
|
||||||
const getCountryData = () => {
|
const getCountryData = () => {
|
||||||
if (!metrics || !metrics.countries) return {};
|
if (!metrics?.countries) return {};
|
||||||
|
return Object.entries(metrics.countries).reduce(
|
||||||
// Convert the countries object from metrics to the format expected by GeographicMap
|
|
||||||
const result = Object.entries(metrics.countries).reduce(
|
|
||||||
(acc, [code, count]) => {
|
(acc, [code, count]) => {
|
||||||
if (code && count) {
|
if (code && count) {
|
||||||
acc[code] = count;
|
acc[code] = count;
|
||||||
@ -164,11 +292,8 @@ function DashboardContent() {
|
|||||||
},
|
},
|
||||||
{} as Record<string, number>
|
{} as Record<string, number>
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to prepare response time distribution data
|
|
||||||
const getResponseTimeData = () => {
|
const getResponseTimeData = () => {
|
||||||
const avgTime = metrics.avgResponseTime || 1.5;
|
const avgTime = metrics.avgResponseTime || 1.5;
|
||||||
const simulatedData: number[] = [];
|
const simulatedData: number[] = [];
|
||||||
@ -183,252 +308,276 @@ function DashboardContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<WelcomeBanner companyName={company.name} />
|
{/* Modern Header */}
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center bg-white p-6 rounded-2xl shadow-lg ring-1 ring-slate-200/50">
|
<Card className="border-0 bg-linear-to-r from-primary/5 via-primary/10 to-primary/5">
|
||||||
<div>
|
<CardHeader>
|
||||||
<h1 className="text-3xl font-bold text-slate-800">{company.name}</h1>
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<p className="text-slate-500 mt-1">
|
<div className="space-y-2">
|
||||||
Dashboard updated{" "}
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-medium text-slate-600">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
{company.name}
|
||||||
|
</h1>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Analytics Dashboard
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Last updated{" "}
|
||||||
|
<span className="font-medium">
|
||||||
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-4 sm:mt-0">
|
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
className="bg-sky-600 text-white py-2 px-5 rounded-lg shadow hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center text-sm font-medium"
|
<Button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={refreshing || isAuditor}
|
disabled={refreshing || isAuditor}
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
aria-label={
|
||||||
|
refreshing
|
||||||
|
? "Refreshing dashboard data"
|
||||||
|
: "Refresh dashboard data"
|
||||||
|
}
|
||||||
|
aria-describedby={refreshing ? refreshStatusId : undefined}
|
||||||
>
|
>
|
||||||
{refreshing ? (
|
<RefreshCw
|
||||||
<>
|
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||||
<svg
|
aria-hidden="true"
|
||||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
/>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
{refreshing ? "Refreshing..." : "Refresh"}
|
||||||
fill="none"
|
</Button>
|
||||||
viewBox="0 0 24 24"
|
{refreshing && (
|
||||||
|
<div
|
||||||
|
id={refreshStatusId}
|
||||||
|
className="sr-only"
|
||||||
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<circle
|
Dashboard data is being refreshed
|
||||||
className="opacity-25"
|
</div>
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Refreshing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Refresh Data"
|
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
<button
|
<DropdownMenu>
|
||||||
className="bg-slate-100 text-slate-700 py-2 px-5 rounded-lg shadow hover:bg-slate-200 transition-colors flex items-center text-sm font-medium"
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" aria-label="Account menu">
|
||||||
|
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
>
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Date Range Picker - Temporarily disabled to debug infinite loop */}
|
||||||
|
{/* {dateRange && (
|
||||||
|
<DateRangePicker
|
||||||
|
minDate={dateRange.minDate}
|
||||||
|
maxDate={dateRange.maxDate}
|
||||||
|
onDateRangeChange={handleDateRangeChange}
|
||||||
|
initialStartDate={selectedStartDate}
|
||||||
|
initialEndDate={selectedEndDate}
|
||||||
|
/>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* Modern Metrics Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Total Sessions"
|
title="Total Sessions"
|
||||||
value={metrics.totalSessions}
|
value={metrics.totalSessions?.toLocaleString()}
|
||||||
icon={
|
icon={<MessageSquare className="h-5 w-5" />}
|
||||||
<svg
|
|
||||||
className="h-5 w-5"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
trend={{
|
trend={{
|
||||||
value: metrics.sessionTrend ?? 0,
|
value: metrics.sessionTrend ?? 0,
|
||||||
isPositive: (metrics.sessionTrend ?? 0) >= 0,
|
isPositive: (metrics.sessionTrend ?? 0) >= 0,
|
||||||
}}
|
}}
|
||||||
|
variant="primary"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Unique Users"
|
title="Unique Users"
|
||||||
value={metrics.uniqueUsers}
|
value={metrics.uniqueUsers?.toLocaleString()}
|
||||||
icon={
|
icon={<Users className="h-5 w-5" />}
|
||||||
<svg
|
|
||||||
className="h-5 w-5"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
trend={{
|
trend={{
|
||||||
value: metrics.usersTrend ?? 0,
|
value: metrics.usersTrend ?? 0,
|
||||||
isPositive: (metrics.usersTrend ?? 0) >= 0,
|
isPositive: (metrics.usersTrend ?? 0) >= 0,
|
||||||
}}
|
}}
|
||||||
|
variant="success"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Avg. Session Time"
|
title="Avg. Session Time"
|
||||||
value={`${Math.round(metrics.avgSessionLength || 0)}s`}
|
value={`${Math.round(metrics.avgSessionLength || 0)}s`}
|
||||||
icon={
|
icon={<Clock className="h-5 w-5" />}
|
||||||
<svg
|
|
||||||
className="h-5 w-5"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
trend={{
|
trend={{
|
||||||
value: metrics.avgSessionTimeTrend ?? 0,
|
value: metrics.avgSessionTimeTrend ?? 0,
|
||||||
isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
|
isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Avg. Response Time"
|
title="Avg. Response Time"
|
||||||
value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`}
|
value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`}
|
||||||
icon={
|
icon={<Zap className="h-5 w-5" />}
|
||||||
<svg
|
|
||||||
className="h-5 w-5"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
trend={{
|
trend={{
|
||||||
value: metrics.avgResponseTimeTrend ?? 0,
|
value: metrics.avgResponseTimeTrend ?? 0,
|
||||||
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0, // Lower response time is better
|
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0,
|
||||||
}}
|
}}
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCard
|
||||||
|
title="Daily Costs"
|
||||||
|
value={`€${metrics.avgDailyCosts?.toFixed(4) || "0.0000"}`}
|
||||||
|
icon={<Euro className="h-5 w-5" />}
|
||||||
|
description="Average per day"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCard
|
||||||
|
title="Peak Usage"
|
||||||
|
value={metrics.peakUsageTime || "N/A"}
|
||||||
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
|
description="Busiest hour"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCard
|
||||||
|
title="Resolution Rate"
|
||||||
|
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || "0.0"}%`}
|
||||||
|
icon={<CheckCircle className="h-5 w-5" />}
|
||||||
|
trend={{
|
||||||
|
value: metrics.resolvedChatsPercentage ?? 0,
|
||||||
|
isPositive: (metrics.resolvedChatsPercentage ?? 0) >= 80,
|
||||||
|
}}
|
||||||
|
variant={
|
||||||
|
metrics.resolvedChatsPercentage &&
|
||||||
|
metrics.resolvedChatsPercentage >= 80
|
||||||
|
? "success"
|
||||||
|
: "warning"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCard
|
||||||
|
title="Active Languages"
|
||||||
|
value={Object.keys(metrics.languages || {}).length}
|
||||||
|
icon={<Globe className="h-5 w-5" />}
|
||||||
|
description="Languages detected"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Section */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="bg-white p-6 rounded-xl shadow lg:col-span-2">
|
<ModernLineChart
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
data={getSessionsOverTimeData()}
|
||||||
Sessions Over Time
|
title="Sessions Over Time"
|
||||||
</h3>
|
className="lg:col-span-2"
|
||||||
<SessionsLineChart sessionsPerDay={metrics.days} />
|
height={350}
|
||||||
</div>
|
/>
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
<ModernDonutChart
|
||||||
Conversation Sentiment
|
data={getSentimentData()}
|
||||||
</h3>
|
title="Conversation Sentiment"
|
||||||
<DonutChart
|
|
||||||
data={{
|
|
||||||
labels: ["Positive", "Neutral", "Negative"],
|
|
||||||
values: [
|
|
||||||
getSentimentData().positive,
|
|
||||||
getSentimentData().neutral,
|
|
||||||
getSentimentData().negative,
|
|
||||||
],
|
|
||||||
colors: ["#1cad7c", "#a1a1a1", "#dc2626"],
|
|
||||||
}}
|
|
||||||
centerText={{
|
centerText={{
|
||||||
title: "Total",
|
title: "Total",
|
||||||
value: metrics.totalSessions,
|
value: metrics.totalSessions || 0,
|
||||||
}}
|
}}
|
||||||
|
height={350}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
<ModernBarChart
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
data={getCategoriesData()}
|
||||||
Sessions by Category
|
title="Sessions by Category"
|
||||||
</h3>
|
height={350}
|
||||||
<CategoriesBarChart categories={metrics.categories || {}} />
|
/>
|
||||||
</div>
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
<ModernDonutChart
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
data={getLanguagesData()}
|
||||||
Languages Used
|
title="Languages Used"
|
||||||
</h3>
|
height={350}
|
||||||
<LanguagePieChart languages={metrics.languages || {}} />
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Geographic and Topics Section */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
<Card>
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5" />
|
||||||
Geographic Distribution
|
Geographic Distribution
|
||||||
</h3>
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<GeographicMap countries={getCountryData()} />
|
<GeographicMap countries={getCountryData()} />
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
<Card>
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessageCircle className="h-5 w-5" />
|
||||||
Common Topics
|
Common Topics
|
||||||
</h3>
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<div className="h-[300px]">
|
<div className="h-[300px]">
|
||||||
<WordCloud words={getWordCloudData()} width={500} height={400} />
|
<WordCloud words={getWordCloudData()} width={500} height={300} />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
{/* Top Questions Chart */}
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
<TopQuestionsChart data={metrics.topQuestions || []} />
|
||||||
Response Time Distribution
|
|
||||||
</h3>
|
{/* Response Time Distribution */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Response Time Distribution</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<ResponseTimeDistribution
|
<ResponseTimeDistribution
|
||||||
data={getResponseTimeData()}
|
data={getResponseTimeData()}
|
||||||
average={metrics.avgResponseTime || 0}
|
average={metrics.avgResponseTime || 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</CardContent>
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
</Card>
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800">
|
{/* Token Usage Summary */}
|
||||||
Token Usage & Costs
|
<Card>
|
||||||
</h3>
|
<CardHeader>
|
||||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 w-full sm:w-auto">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<div className="text-sm bg-blue-50 text-blue-700 px-3 py-1 rounded-full flex items-center">
|
<CardTitle>AI Usage & Costs</CardTitle>
|
||||||
<span className="font-semibold mr-1">Total Tokens:</span>
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<span className="font-semibold">Total Tokens:</span>
|
||||||
{metrics.totalTokens?.toLocaleString() || 0}
|
{metrics.totalTokens?.toLocaleString() || 0}
|
||||||
</div>
|
</Badge>
|
||||||
<div className="text-sm bg-green-50 text-green-700 px-3 py-1 rounded-full flex items-center">
|
<Badge variant="outline" className="gap-1">
|
||||||
<span className="font-semibold mr-1">Total Cost:</span>€
|
<span className="font-semibold">Total Cost:</span>€
|
||||||
{metrics.totalTokensEur?.toFixed(4) || 0}
|
{metrics.totalTokensEur?.toFixed(4) || 0}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<p>Token usage chart will be implemented with historical data</p>
|
||||||
</div>
|
</div>
|
||||||
<TokenUsageChart tokenData={getTokenData()} />
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Our exported component
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return <DashboardContent />;
|
return <DashboardContent />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSession } from "next-auth/react";
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
BarChart3,
|
||||||
|
MessageSquare,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useSession } from "next-auth/react";
|
||||||
import { FC } from "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 DashboardPage: FC = () => {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
@ -21,82 +33,244 @@ const DashboardPage: FC = () => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[40vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="text-center">
|
<div className="text-center space-y-4">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-sky-500 mx-auto mb-4"></div>
|
<div className="relative">
|
||||||
<p className="text-lg text-gray-600">Loading dashboard...</p>
|
<div className="animate-spin rounded-full h-12 w-12 border-2 border-muted border-t-primary mx-auto" />
|
||||||
|
<div className="absolute inset-0 animate-ping rounded-full h-12 w-12 border border-primary opacity-20 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg text-muted-foreground animate-pulse">
|
||||||
|
Loading dashboard...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navigationCards = [
|
||||||
|
{
|
||||||
|
title: "Analytics Overview",
|
||||||
|
description:
|
||||||
|
"View comprehensive metrics, charts, and insights from your chat sessions",
|
||||||
|
icon: <BarChart3 className="h-6 w-6" />,
|
||||||
|
href: "/dashboard/overview",
|
||||||
|
variant: "primary" as const,
|
||||||
|
features: ["Real-time metrics", "Interactive charts", "Trend analysis"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Session Browser",
|
||||||
|
description:
|
||||||
|
"Browse, search, and analyze individual conversation sessions",
|
||||||
|
icon: <MessageSquare className="h-6 w-6" />,
|
||||||
|
href: "/dashboard/sessions",
|
||||||
|
variant: "success" as const,
|
||||||
|
features: ["Session search", "Conversation details", "Export data"],
|
||||||
|
},
|
||||||
|
...(session?.user?.role === "ADMIN"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "Company Settings",
|
||||||
|
description:
|
||||||
|
"Configure company settings, integrations, and API connections",
|
||||||
|
icon: <Settings className="h-6 w-6" />,
|
||||||
|
href: "/dashboard/company",
|
||||||
|
variant: "warning" as const,
|
||||||
|
features: [
|
||||||
|
"API configuration",
|
||||||
|
"Integration settings",
|
||||||
|
"Data management",
|
||||||
|
],
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "User Management",
|
||||||
|
description:
|
||||||
|
"Invite team members and manage user accounts and permissions",
|
||||||
|
icon: <Users className="h-6 w-6" />,
|
||||||
|
href: "/dashboard/users",
|
||||||
|
variant: "default" as const,
|
||||||
|
features: ["User invitations", "Role management", "Access control"],
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const getCardClasses = (variant: string) => {
|
||||||
|
switch (variant) {
|
||||||
|
case "primary":
|
||||||
|
return "border-primary/20 bg-linear-to-br from-primary/5 to-primary/10 hover:from-primary/10 hover:to-primary/15";
|
||||||
|
case "success":
|
||||||
|
return "border-green-200 bg-linear-to-br from-green-50 to-green-100 hover:from-green-100 hover:to-green-150 dark:border-green-800 dark:from-green-950 dark:to-green-900";
|
||||||
|
case "warning":
|
||||||
|
return "border-amber-200 bg-linear-to-br from-amber-50 to-amber-100 hover:from-amber-100 hover:to-amber-150 dark:border-amber-800 dark:from-amber-950 dark:to-amber-900";
|
||||||
|
default:
|
||||||
|
return "border-border bg-linear-to-br from-card to-muted/20 hover:from-muted/30 hover:to-muted/40";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconClasses = (variant: string) => {
|
||||||
|
switch (variant) {
|
||||||
|
case "primary":
|
||||||
|
return "bg-primary/10 text-primary border-primary/20";
|
||||||
|
case "success":
|
||||||
|
return "bg-green-100 text-green-600 border-green-200 dark:bg-green-900 dark:text-green-400 dark:border-green-800";
|
||||||
|
case "warning":
|
||||||
|
return "bg-amber-100 text-amber-600 border-amber-200 dark:bg-amber-900 dark:text-amber-400 dark:border-amber-800";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground border-border";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="bg-white rounded-xl shadow p-6">
|
{/* Welcome Header */}
|
||||||
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
|
<div className="relative overflow-hidden rounded-xl bg-linear-to-r from-primary/10 via-primary/5 to-transparent p-8 border border-primary/10">
|
||||||
|
<div className="absolute inset-0 bg-linear-to-br from-primary/5 to-transparent" />
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="absolute -top-24 -right-24 h-64 w-64 rounded-full bg-primary/10 blur-3xl" />
|
||||||
<div className="bg-gradient-to-br from-sky-50 to-sky-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
<div className="relative">
|
||||||
<h2 className="text-lg font-semibold text-sky-700">Analytics</h2>
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<p className="text-gray-600 mt-2 mb-4">
|
<div className="space-y-3">
|
||||||
View your chat session metrics and analytics
|
<div className="flex items-center gap-3">
|
||||||
</p>
|
<h1 className="text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-linear-to-r from-foreground to-foreground/70">
|
||||||
<button
|
Welcome back, {session?.user?.name || "User"}!
|
||||||
onClick={() => router.push("/dashboard/overview")}
|
</h1>
|
||||||
className="bg-sky-500 hover:bg-sky-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs px-3 py-1 bg-primary/10 text-primary border-primary/20"
|
||||||
>
|
>
|
||||||
View Analytics
|
{session?.user?.role}
|
||||||
</button>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
Choose a section below to explore your analytics dashboard
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
<div className="flex items-center gap-3 px-4 py-2 rounded-full bg-muted/50 backdrop-blur-sm">
|
||||||
<h2 className="text-lg font-semibold text-emerald-700">Sessions</h2>
|
<Shield className="h-4 w-4 text-green-600" />
|
||||||
<p className="text-gray-600 mt-2 mb-4">
|
<span className="text-sm font-medium">Secure Dashboard</span>
|
||||||
Browse and analyze conversation sessions
|
</div>
|
||||||
</p>
|
</div>
|
||||||
<button
|
</div>
|
||||||
onClick={() => router.push("/dashboard/sessions")}
|
|
||||||
className="bg-emerald-500 hover:bg-emerald-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
View Sessions
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{session?.user?.role === "admin" && (
|
{/* Navigation Cards */}
|
||||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<h2 className="text-lg font-semibold text-purple-700">
|
{navigationCards.map((card) => (
|
||||||
Company Settings
|
<Card
|
||||||
</h2>
|
key={card.href}
|
||||||
<p className="text-gray-600 mt-2 mb-4">
|
className={`relative overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-1 cursor-pointer group ${getCardClasses(
|
||||||
Configure company settings and integrations
|
card.variant
|
||||||
</p>
|
)}`}
|
||||||
<button
|
onClick={() => router.push(card.href)}
|
||||||
onClick={() => router.push("/dashboard/company")}
|
|
||||||
className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
>
|
||||||
Manage Settings
|
{/* Subtle gradient overlay */}
|
||||||
</button>
|
<div className="absolute inset-0 bg-linear-to-br from-white/50 to-transparent dark:from-white/5 pointer-events-none" />
|
||||||
|
|
||||||
|
<CardHeader className="relative">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full border transition-all duration-300 group-hover:scale-110 ${getIconClasses(
|
||||||
|
card.variant
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
<span className="transition-transform duration-300 group-hover:scale-110">
|
||||||
|
{card.icon}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
{card.title}
|
||||||
|
{card.adminOnly && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
</CardTitle>
|
||||||
{session?.user?.role === "admin" && (
|
</div>
|
||||||
<div className="bg-gradient-to-br from-amber-50 to-amber-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-amber-700">
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
User Management
|
{card.description}
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mt-2 mb-4">
|
|
||||||
Invite and manage user accounts
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
</div>
|
||||||
onClick={() => router.push("/dashboard/users")}
|
</div>
|
||||||
className="bg-amber-500 hover:bg-amber-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="relative space-y-4">
|
||||||
|
{/* Features List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{card.features.map((feature) => (
|
||||||
|
<div
|
||||||
|
key={feature}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
Manage Users
|
<Zap className="h-3 w-3 text-primary/60" />
|
||||||
</button>
|
<span className="text-muted-foreground">{feature}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<Button
|
||||||
|
className="w-full gap-2 mt-4 group-hover:gap-3 transition-all duration-300"
|
||||||
|
variant={card.variant === "primary" ? "default" : "outline"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(card.href);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{card.title === "Analytics Overview" && "View Analytics"}
|
||||||
|
{card.title === "Session Browser" && "Browse Sessions"}
|
||||||
|
{card.title === "Company Settings" && "Manage Settings"}
|
||||||
|
{card.title === "User Management" && "Manage Users"}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" />
|
||||||
|
Quick Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Zap className="h-5 w-5 text-primary" />
|
||||||
|
<span className="text-2xl font-bold">Real-time</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Data updates</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-green-600" />
|
||||||
|
<span className="text-2xl font-bold">Secure</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Data protection</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-blue-600" />
|
||||||
|
<span className="text-2xl font-bold">Advanced</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Analytics</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,16 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import {
|
||||||
import { useParams, useRouter } from "next/navigation"; // Import useRouter
|
Activity,
|
||||||
import { useSession } from "next-auth/react"; // Import useSession
|
AlertCircle,
|
||||||
import SessionDetails from "../../../../components/SessionDetails";
|
ArrowLeft,
|
||||||
import TranscriptViewer from "../../../../components/TranscriptViewer";
|
Clock,
|
||||||
import { ChatSession } from "../../../../lib/types";
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
|
Globe,
|
||||||
|
MessageSquare,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
interface SessionApiResponse {
|
import { useSession } from "next-auth/react";
|
||||||
session: ChatSession;
|
import { 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 { 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() {
|
export default function SessionViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -34,13 +45,13 @@ export default function SessionViewPage() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/dashboard/session/${id}`);
|
const response = await fetch(`/api/dashboard/session/${id}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = (await response.json()) as { error: string };
|
const errorData = await response.json();
|
||||||
throw new Error(
|
throw new Error(
|
||||||
errorData.error ||
|
errorData.error ||
|
||||||
`Failed to fetch session: ${response.statusText}`
|
`Failed to fetch session: ${response.statusText}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as SessionApiResponse;
|
const data = await response.json();
|
||||||
setSession(data.session);
|
setSession(data.session);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@ -60,115 +71,256 @@ export default function SessionViewPage() {
|
|||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 flex justify-center items-center min-h-screen">
|
<div className="space-y-6">
|
||||||
<p className="text-gray-600 text-lg">Loading session...</p>
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Loading session...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 flex justify-center items-center min-h-screen">
|
<div className="space-y-6">
|
||||||
<p className="text-gray-600 text-lg">Redirecting to login...</p>
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Redirecting to login...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && status === "authenticated") {
|
if (loading && status === "authenticated") {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 flex justify-center items-center min-h-screen">
|
<div className="space-y-6">
|
||||||
<p className="text-gray-600 text-lg">Loading session details...</p>
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Loading session details...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 min-h-screen">
|
<div className="space-y-6">
|
||||||
<p className="text-red-500 text-lg mb-4">Error: {error}</p>
|
<Card>
|
||||||
<Link
|
<CardContent className="pt-6">
|
||||||
href="/dashboard/sessions"
|
<div className="text-center py-8">
|
||||||
className="text-sky-600 hover:underline"
|
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||||
>
|
<p className="text-destructive text-lg mb-4">Error: {error}</p>
|
||||||
|
<Link href="/dashboard/sessions">
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back to Sessions List
|
Back to Sessions List
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 min-h-screen">
|
<div className="space-y-6">
|
||||||
<p className="text-gray-600 text-lg mb-4">Session not found.</p>
|
<Card>
|
||||||
<Link
|
<CardContent className="pt-6">
|
||||||
href="/dashboard/sessions"
|
<div className="text-center py-8">
|
||||||
className="text-sky-600 hover:underline"
|
<MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
>
|
<p className="text-muted-foreground text-lg mb-4">
|
||||||
|
Session not found.
|
||||||
|
</p>
|
||||||
|
<Link href="/dashboard/sessions">
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back to Sessions List
|
Back to Sessions List
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-100 p-4 md:p-6">
|
<div className="space-y-6 max-w-6xl mx-auto">
|
||||||
<div className="max-w-4xl mx-auto">
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<Card>
|
||||||
<Link
|
<CardContent className="pt-6">
|
||||||
href="/dashboard/sessions"
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
className="text-sky-700 hover:text-sky-900 hover:underline flex items-center"
|
<div className="space-y-2">
|
||||||
|
<Link href="/dashboard/sessions">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="gap-2 p-0 h-auto focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||||
|
aria-label="Return to sessions list"
|
||||||
>
|
>
|
||||||
<svg
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-5 w-5 mr-1"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Back to Sessions List
|
Back to Sessions List
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold">Session Details</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
ID
|
||||||
|
</Badge>
|
||||||
|
<code className="text-sm text-muted-foreground font-mono">
|
||||||
|
{(session.sessionId || session.id).slice(0, 8)}...
|
||||||
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">
|
</div>
|
||||||
Session: {session.sessionId || session.id}
|
</div>
|
||||||
</h1>
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="grid grid-cols-1 gap-6">
|
{session.category && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Activity className="h-3 w-3" />
|
||||||
|
{formatCategory(session.category)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{session.language && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
{session.language.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{session.sentiment && (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
session.sentiment === "positive"
|
||||||
|
? "default"
|
||||||
|
: session.sentiment === "negative"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{session.sentiment.charAt(0).toUpperCase() +
|
||||||
|
session.sentiment.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Session Overview */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="h-8 w-8 text-blue-500" />
|
||||||
<div>
|
<div>
|
||||||
<SessionDetails session={session} />
|
<p className="text-sm text-muted-foreground">Start Time</p>
|
||||||
</div>
|
<p className="font-semibold">
|
||||||
{session.transcriptContent &&
|
{new Date(session.startTime).toLocaleString()}
|
||||||
session.transcriptContent.trim() !== "" ? (
|
|
||||||
<div className="mt-0">
|
|
||||||
<TranscriptViewer
|
|
||||||
transcriptContent={session.transcriptContent}
|
|
||||||
transcriptUrl={session.fullTranscriptUrl}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white p-4 rounded-lg shadow">
|
|
||||||
<h3 className="font-bold text-lg mb-3">Transcript</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
No transcript content available for this session.
|
|
||||||
</p>
|
</p>
|
||||||
{session.fullTranscriptUrl &&
|
</div>
|
||||||
process.env.NODE_ENV !== "production" && (
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MessageSquare className="h-8 w-8 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Messages</p>
|
||||||
|
<p className="font-semibold">{session.messages?.length || 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<User className="h-8 w-8 text-purple-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">User ID</p>
|
||||||
|
<p className="font-semibold truncate">
|
||||||
|
{session.userId || "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Activity className="h-8 w-8 text-orange-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Duration</p>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{session.endTime && session.startTime
|
||||||
|
? `${Math.round(
|
||||||
|
(new Date(session.endTime).getTime() -
|
||||||
|
new Date(session.startTime).getTime()) /
|
||||||
|
60000
|
||||||
|
)} min`
|
||||||
|
: "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session Details */}
|
||||||
|
<SessionDetails session={session} />
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{session.messages && session.messages.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
Conversation ({session.messages.length} messages)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<MessageViewer messages={session.messages} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transcript URL */}
|
||||||
|
{session.fullTranscriptUrl && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Source Transcript
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<a
|
<a
|
||||||
href={session.fullTranscriptUrl}
|
href={session.fullTranscriptUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sky-600 hover:underline mt-2 inline-block"
|
className="inline-flex items-center gap-2 text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||||
|
aria-label="Open original transcript in new tab"
|
||||||
>
|
>
|
||||||
View Source Transcript URL
|
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||||
|
View Original Transcript
|
||||||
</a>
|
</a>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import {
|
||||||
import { ChatSession } from "../../../lib/types";
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
Filter,
|
||||||
|
Globe,
|
||||||
|
MessageSquare,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useId, 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
|
// Placeholder for a SessionListItem component to be created later
|
||||||
// For now, we'll display some basic info directly.
|
// For now, we'll display some basic info directly.
|
||||||
@ -14,17 +32,29 @@ interface FilterOptions {
|
|||||||
languages: string[];
|
languages: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionsApiResponse {
|
|
||||||
sessions: ChatSession[];
|
|
||||||
totalSessions: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SessionsPage() {
|
export default function SessionsPage() {
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
const searchHeadingId = useId();
|
||||||
|
const filtersHeadingId = useId();
|
||||||
|
const filterContentId = useId();
|
||||||
|
const categoryFilterId = useId();
|
||||||
|
const categoryHelpId = useId();
|
||||||
|
const languageFilterId = useId();
|
||||||
|
const languageHelpId = useId();
|
||||||
|
const sortOrderId = useId();
|
||||||
|
const sortOrderHelpId = useId();
|
||||||
|
const resultsHeadingId = useId();
|
||||||
|
const startDateFilterId = useId();
|
||||||
|
const startDateHelpId = useId();
|
||||||
|
const endDateFilterId = useId();
|
||||||
|
const endDateHelpId = useId();
|
||||||
|
const sortKeyId = useId();
|
||||||
|
const sortKeyHelpId = useId();
|
||||||
|
|
||||||
// Filter states
|
// Filter states
|
||||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||||
categories: [],
|
categories: [],
|
||||||
@ -46,7 +76,10 @@ export default function SessionsPage() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
// 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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timerId = setTimeout(() => {
|
const timerId = setTimeout(() => {
|
||||||
@ -63,7 +96,7 @@ export default function SessionsPage() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to fetch filter options");
|
throw new Error("Failed to fetch filter options");
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as FilterOptions;
|
const data = await response.json();
|
||||||
setFilterOptions(data);
|
setFilterOptions(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@ -93,7 +126,7 @@ export default function SessionsPage() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch sessions: ${response.statusText}`);
|
throw new Error(`Failed to fetch sessions: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as SessionsApiResponse;
|
const data = await response.json();
|
||||||
setSessions(data.sessions || []);
|
setSessions(data.sessions || []);
|
||||||
setTotalPages(Math.ceil((data.totalSessions || 0) / pageSize));
|
setTotalPages(Math.ceil((data.totalSessions || 0) / pageSize));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -125,60 +158,115 @@ export default function SessionsPage() {
|
|||||||
}, [fetchFilterOptions]);
|
}, [fetchFilterOptions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-semibold text-gray-800 mb-6">
|
{/* Page heading for screen readers */}
|
||||||
Chat Sessions
|
<h1 className="sr-only">Sessions Management</h1>
|
||||||
</h1>
|
|
||||||
|
{/* Header */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MessageSquare className="h-6 w-6" />
|
||||||
|
<CardTitle as="h2">Chat Sessions</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<div className="mb-4">
|
<section aria-labelledby={searchHeadingId}>
|
||||||
<input
|
<h2 id={searchHeadingId} className="sr-only">
|
||||||
type="text"
|
Search Sessions
|
||||||
|
</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
placeholder="Search sessions (ID, category, initial message...)"
|
placeholder="Search sessions (ID, category, initial message...)"
|
||||||
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
aria-label="Search sessions by ID, category, or message content"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Filter and Sort Controls */}
|
{/* Filter and Sort Controls */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6 p-4 bg-gray-50 rounded-lg shadow">
|
<section aria-labelledby={filtersHeadingId}>
|
||||||
{/* Category Filter */}
|
<Card>
|
||||||
<div>
|
<CardHeader>
|
||||||
<label
|
<div className="flex items-center justify-between">
|
||||||
htmlFor="category-filter"
|
<div className="flex items-center gap-2">
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
<Filter className="h-5 w-5" aria-hidden="true" />
|
||||||
|
<CardTitle as="h2" id={filtersHeadingId} className="text-lg">
|
||||||
|
Filters & Sorting
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||||
|
className="gap-2"
|
||||||
|
aria-expanded={filtersExpanded}
|
||||||
|
aria-controls={filterContentId}
|
||||||
>
|
>
|
||||||
Category
|
{filtersExpanded ? (
|
||||||
</label>
|
<>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
Hide
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
Show
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{filtersExpanded && (
|
||||||
|
<CardContent id={filterContentId}>
|
||||||
|
<fieldset>
|
||||||
|
<legend className="sr-only">
|
||||||
|
Session Filters and Sorting Options
|
||||||
|
</legend>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={categoryFilterId}>Category</Label>
|
||||||
<select
|
<select
|
||||||
id="category-filter"
|
id={categoryFilterId}
|
||||||
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
value={selectedCategory}
|
value={selectedCategory}
|
||||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
aria-describedby={categoryHelpId}
|
||||||
>
|
>
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
{filterOptions.categories.map((cat) => (
|
{filterOptions.categories.map((cat) => (
|
||||||
<option key={cat} value={cat}>
|
<option key={cat} value={cat}>
|
||||||
{cat}
|
{formatCategory(cat)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<div id={categoryHelpId} className="sr-only">
|
||||||
|
Filter sessions by category type
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Language Filter */}
|
{/* Language Filter */}
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<Label htmlFor={languageFilterId}>Language</Label>
|
||||||
htmlFor="language-filter"
|
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Language
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
id="language-filter"
|
id={languageFilterId}
|
||||||
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
value={selectedLanguage}
|
value={selectedLanguage}
|
||||||
onChange={(e) => setSelectedLanguage(e.target.value)}
|
onChange={(e) => setSelectedLanguage(e.target.value)}
|
||||||
|
aria-describedby={languageHelpId}
|
||||||
>
|
>
|
||||||
<option value="">All Languages</option>
|
<option value="">All Languages</option>
|
||||||
{filterOptions.languages.map((lang) => (
|
{filterOptions.languages.map((lang) => (
|
||||||
@ -187,166 +275,273 @@ export default function SessionsPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<div id={languageHelpId} className="sr-only">
|
||||||
|
Filter sessions by language
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Start Date Filter */}
|
{/* Start Date Filter */}
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<Label htmlFor={startDateFilterId}>Start Date</Label>
|
||||||
htmlFor="start-date-filter"
|
<Input
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Start Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
type="date"
|
||||||
id="start-date-filter"
|
id={startDateFilterId}
|
||||||
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
aria-describedby={startDateHelpId}
|
||||||
/>
|
/>
|
||||||
|
<div id={startDateHelpId} className="sr-only">
|
||||||
|
Filter sessions from this date onwards
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* End Date Filter */}
|
{/* End Date Filter */}
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<Label htmlFor={endDateFilterId}>End Date</Label>
|
||||||
htmlFor="end-date-filter"
|
<Input
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
End Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
type="date"
|
||||||
id="end-date-filter"
|
id={endDateFilterId}
|
||||||
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
aria-describedby={endDateHelpId}
|
||||||
/>
|
/>
|
||||||
|
<div id={endDateHelpId} className="sr-only">
|
||||||
|
Filter sessions up to this date
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort Key */}
|
{/* Sort Key */}
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<Label htmlFor={sortKeyId}>Sort By</Label>
|
||||||
htmlFor="sort-key"
|
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Sort By
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
id="sort-key"
|
id={sortKeyId}
|
||||||
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
value={sortKey}
|
value={sortKey}
|
||||||
onChange={(e) => setSortKey(e.target.value)}
|
onChange={(e) => setSortKey(e.target.value)}
|
||||||
|
aria-describedby={sortKeyHelpId}
|
||||||
>
|
>
|
||||||
<option value="startTime">Start Time</option>
|
<option value="startTime">Start Time</option>
|
||||||
<option value="category">Category</option>
|
<option value="category">Category</option>
|
||||||
<option value="language">Language</option>
|
<option value="language">Language</option>
|
||||||
<option value="sentiment">Sentiment</option>
|
<option value="sentiment">Sentiment</option>
|
||||||
<option value="messagesSent">Messages Sent</option>
|
<option value="messagesSent">Messages Sent</option>
|
||||||
<option value="avgResponseTime">Avg. Response Time</option>
|
<option value="avgResponseTime">
|
||||||
|
Avg. Response Time
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div id={sortKeyHelpId} className="sr-only">
|
||||||
|
Choose field to sort sessions by
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort Order */}
|
{/* Sort Order */}
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<Label htmlFor={sortOrderId}>Order</Label>
|
||||||
htmlFor="sort-order"
|
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Order
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
id="sort-order"
|
id={sortOrderId}
|
||||||
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
onChange={(e) => setSortOrder(e.target.value as "asc" | "desc")}
|
onChange={(e) =>
|
||||||
|
setSortOrder(e.target.value as "asc" | "desc")
|
||||||
|
}
|
||||||
|
aria-describedby={sortOrderHelpId}
|
||||||
>
|
>
|
||||||
<option value="desc">Descending</option>
|
<option value="desc">Descending</option>
|
||||||
<option value="asc">Ascending</option>
|
<option value="asc">Ascending</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div id={sortOrderHelpId} className="sr-only">
|
||||||
|
Choose ascending or descending order
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
{loading && <p className="text-gray-600">Loading sessions...</p>}
|
{/* Results section */}
|
||||||
{error && <p className="text-red-500">Error: {error}</p>}
|
<section aria-labelledby={resultsHeadingId}>
|
||||||
|
<h2 id={resultsHeadingId} className="sr-only">
|
||||||
|
Session Results
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Live region for screen reader announcements */}
|
||||||
|
<output aria-live="polite" className="sr-only">
|
||||||
|
{loading && "Loading sessions..."}
|
||||||
|
{error && `Error loading sessions: ${error}`}
|
||||||
|
{!loading &&
|
||||||
|
!error &&
|
||||||
|
sessions.length > 0 &&
|
||||||
|
`Found ${sessions.length} sessions`}
|
||||||
|
{!loading && !error && sessions.length === 0 && "No sessions found"}
|
||||||
|
</output>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
Loading sessions...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div
|
||||||
|
className="text-center py-8 text-destructive"
|
||||||
|
role="alert"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
{!loading && !error && sessions.length === 0 && (
|
{!loading && !error && sessions.length === 0 && (
|
||||||
<p className="text-gray-600">
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
{debouncedSearchTerm
|
{debouncedSearchTerm
|
||||||
? `No sessions found for "${debouncedSearchTerm}".`
|
? `No sessions found for "${debouncedSearchTerm}".`
|
||||||
: "No sessions found."}
|
: "No sessions found."}
|
||||||
</p>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sessions List */}
|
||||||
{!loading && !error && sessions.length > 0 && (
|
{!loading && !error && sessions.length > 0 && (
|
||||||
<div className="space-y-4">
|
<ul aria-label="Chat sessions" className="grid gap-4">
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<div
|
<li key={session.id}>
|
||||||
key={session.id}
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
className="bg-white p-4 rounded-lg shadow hover:shadow-md transition-shadow"
|
<CardContent className="pt-6">
|
||||||
|
<article aria-labelledby={`session-${session.id}-title`}>
|
||||||
|
<header className="flex justify-between items-start mb-4">
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<h3
|
||||||
|
id={`session-${session.id}-title`}
|
||||||
|
className="sr-only"
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold text-sky-700 mb-1">
|
Session {session.sessionId || session.id} from{" "}
|
||||||
Session ID: {session.sessionId || session.id}
|
{new Date(session.startTime).toLocaleDateString()}
|
||||||
</h2>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 mb-1">
|
<div className="flex items-center gap-3">
|
||||||
Start Time{/* (Local) */}:{" "}
|
<Badge
|
||||||
{new Date(session.startTime).toLocaleString()}
|
variant="outline"
|
||||||
</p>
|
className="font-mono text-xs"
|
||||||
{/* <p className="text-xs text-gray-400 mb-1">
|
>
|
||||||
Start Time (Raw API): {session.startTime.toString()}
|
ID
|
||||||
</p> */}
|
</Badge>
|
||||||
|
<code className="text-sm text-muted-foreground font-mono truncate max-w-24">
|
||||||
|
{session.sessionId || session.id}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Clock
|
||||||
|
className="h-3 w-3 mr-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{new Date(session.startTime).toLocaleDateString()}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(session.startTime).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href={`/dashboard/sessions/${session.id}`}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
aria-label={`View details for session ${session.sessionId || session.id}`}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
View Details
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
{session.category && (
|
{session.category && (
|
||||||
<p className="text-sm text-gray-700">
|
<Badge variant="secondary" className="gap-1">
|
||||||
Category:{" "}
|
<Filter className="h-3 w-3" aria-hidden="true" />
|
||||||
<span className="font-medium">{session.category}</span>
|
{formatCategory(session.category)}
|
||||||
</p>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{session.language && (
|
{session.language && (
|
||||||
<p className="text-sm text-gray-700">
|
<Badge variant="outline" className="gap-1">
|
||||||
Language:{" "}
|
<Globe className="h-3 w-3" aria-hidden="true" />
|
||||||
<span className="font-medium">
|
|
||||||
{session.language.toUpperCase()}
|
{session.language.toUpperCase()}
|
||||||
</span>
|
</Badge>
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{session.initialMsg && (
|
|
||||||
<p className="text-sm text-gray-600 mt-1 truncate">
|
|
||||||
Initial Message: {session.initialMsg}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/sessions/${session.id}`}
|
|
||||||
className="mt-2 text-sm text-sky-600 hover:text-sky-800 hover:underline inline-block"
|
|
||||||
>
|
|
||||||
View Details
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{session.summary ? (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{session.summary}
|
||||||
|
</p>
|
||||||
|
) : session.initialMsg ? (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{session.initialMsg}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
{totalPages > 0 && (
|
{totalPages > 0 && (
|
||||||
<div className="mt-6 flex justify-center items-center space-x-2">
|
<Card>
|
||||||
<button
|
<CardContent className="pt-6">
|
||||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
<div className="flex justify-center items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(prev - 1, 1))
|
||||||
|
}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</Button>
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-muted-foreground">
|
||||||
Page {currentPage} of {totalPages}
|
Page {currentPage} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||||
}
|
}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Company } from "../../lib/types";
|
import type { Company } from "../../lib/types";
|
||||||
import { Session } from "next-auth";
|
|
||||||
|
|
||||||
interface DashboardSettingsProps {
|
interface DashboardSettingsProps {
|
||||||
company: Company;
|
company: Company;
|
||||||
@ -37,7 +37,7 @@ export default function DashboardSettings({
|
|||||||
else setMessage("Failed.");
|
else setMessage("Failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== "admin") return null;
|
if (session.user.role !== "ADMIN") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-6 rounded-xl shadow mb-6">
|
<div className="bg-white p-6 rounded-xl shadow mb-6">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { UserSession } from "../../lib/types";
|
import type { UserSession } from "../../lib/types";
|
||||||
|
|
||||||
interface UserItem {
|
interface UserItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -12,10 +12,6 @@ interface UserManagementProps {
|
|||||||
session: UserSession;
|
session: UserSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsersApiResponse {
|
|
||||||
users: UserItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserManagement({ session }: UserManagementProps) {
|
export default function UserManagement({ session }: UserManagementProps) {
|
||||||
const [users, setUsers] = useState<UserItem[]>([]);
|
const [users, setUsers] = useState<UserItem[]>([]);
|
||||||
const [email, setEmail] = useState<string>("");
|
const [email, setEmail] = useState<string>("");
|
||||||
@ -25,7 +21,7 @@ export default function UserManagement({ session }: UserManagementProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/dashboard/users")
|
fetch("/api/dashboard/users")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => setUsers((data as UsersApiResponse).users));
|
.then((data) => setUsers(data.users));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function inviteUser() {
|
async function inviteUser() {
|
||||||
@ -38,7 +34,7 @@ export default function UserManagement({ session }: UserManagementProps) {
|
|||||||
else setMsg("Failed.");
|
else setMsg("Failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== "admin") return null;
|
if (session.user.role !== "ADMIN") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-6 rounded-xl shadow mb-6">
|
<div className="bg-white p-6 rounded-xl shadow mb-6">
|
||||||
@ -56,10 +52,11 @@ export default function UserManagement({ session }: UserManagementProps) {
|
|||||||
onChange={(e) => setRole(e.target.value)}
|
onChange={(e) => setRole(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="user">User</option>
|
<option value="user">User</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="ADMIN">Admin</option>
|
||||||
<option value="auditor">Auditor</option>
|
<option value="AUDITOR">Auditor</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="bg-blue-600 text-white rounded px-4 py-2 sm:py-0 w-full sm:w-auto"
|
className="bg-blue-600 text-white rounded px-4 py-2 sm:py-0 w-full sm:w-auto"
|
||||||
onClick={inviteUser}
|
onClick={inviteUser}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,7 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { AlertCircle, Eye, Shield, UserPlus, Users } from "lucide-react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
interface UserItem {
|
interface UserItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -9,54 +31,40 @@ interface UserItem {
|
|||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsersApiResponse {
|
|
||||||
users: UserItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserManagementPage() {
|
export default function UserManagementPage() {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const [users, setUsers] = useState<UserItem[]>([]);
|
const [users, setUsers] = useState<UserItem[]>([]);
|
||||||
const [email, setEmail] = useState<string>("");
|
const [email, setEmail] = useState<string>("");
|
||||||
const [role, setRole] = useState<string>("user");
|
const [role, setRole] = useState<string>("USER");
|
||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const emailId = useId();
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchUsers = useCallback(async () => {
|
||||||
if (status === "authenticated") {
|
|
||||||
fetchUsers();
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/dashboard/users");
|
const res = await fetch("/api/dashboard/users");
|
||||||
const data = await res.json() as UsersApiResponse | { error: string; };
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok && 'users' in data) {
|
|
||||||
setUsers(data.users);
|
setUsers(data.users);
|
||||||
} else {
|
|
||||||
const errorMessage = 'error' in data ? data.error : "Unknown error";
|
|
||||||
console.error("Failed to fetch users:", errorMessage);
|
|
||||||
|
|
||||||
if (errorMessage === "Admin access required") {
|
|
||||||
setMessage("You need admin privileges to manage users.");
|
|
||||||
} else if (errorMessage === "Not logged in") {
|
|
||||||
setMessage("Please log in to access this page.");
|
|
||||||
} else {
|
|
||||||
setMessage(`Failed to load users: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setUsers([]); // Set empty array to prevent undefined errors
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch users:", error);
|
console.error("Failed to fetch users:", error);
|
||||||
setMessage("Failed to load users.");
|
setMessage("Failed to load users.");
|
||||||
setUsers([]); // Set empty array to prevent undefined errors
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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() {
|
async function inviteUser() {
|
||||||
setMessage("");
|
setMessage("");
|
||||||
@ -73,7 +81,7 @@ export default function UserManagementPage() {
|
|||||||
// Refresh the user list
|
// Refresh the user list
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} else {
|
} else {
|
||||||
const error = (await res.json()) as { message?: string; };
|
const error = await res.json();
|
||||||
setMessage(
|
setMessage(
|
||||||
`Failed to invite user: ${error.message || "Unknown error"}`
|
`Failed to invite user: ${error.message || "Unknown error"}`
|
||||||
);
|
);
|
||||||
@ -86,157 +94,180 @@ export default function UserManagementPage() {
|
|||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-center py-10">Loading users...</div>;
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Loading users...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for admin access
|
// Check for admin access
|
||||||
if (session?.user?.role !== "admin") {
|
if (session?.user?.role !== "ADMIN") {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
<div className="space-y-6">
|
||||||
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
<Card>
|
||||||
<p>You don't have permission to view user management.</p>
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||||
|
<h2 className="font-bold text-xl text-destructive mb-2">
|
||||||
|
Access Denied
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
You don't have permission to view user management.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6" data-testid="user-management-page">
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
{/* Header */}
|
||||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-6 w-6" />
|
||||||
User Management
|
User Management
|
||||||
</h1>
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Message Alert */}
|
||||||
{message && (
|
{message && (
|
||||||
<div
|
<Alert variant={message.includes("Failed") ? "destructive" : "default"}>
|
||||||
className={`p-4 rounded mb-6 ${message.includes("Failed") ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}
|
<AlertDescription>{message}</AlertDescription>
|
||||||
>
|
</Alert>
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-8">
|
{/* Invite New User */}
|
||||||
<h2 className="text-lg font-semibold mb-4">Invite New User</h2>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<UserPlus className="h-5 w-5" />
|
||||||
|
Invite New User
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<form
|
<form
|
||||||
className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end"
|
className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inviteUser();
|
inviteUser();
|
||||||
}}
|
}}
|
||||||
autoComplete="off" // Disable autofill for the form
|
autoComplete="off"
|
||||||
|
data-testid="invite-form"
|
||||||
>
|
>
|
||||||
<div className="grid gap-2">
|
<div className="space-y-2">
|
||||||
<label className="font-medium text-gray-700">Email</label>
|
<Label htmlFor={emailId}>Email</Label>
|
||||||
<input
|
<Input
|
||||||
|
id={emailId}
|
||||||
type="email"
|
type="email"
|
||||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
|
||||||
placeholder="user@example.com"
|
placeholder="user@example.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="off" // Disable autofill for this input
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="space-y-2">
|
||||||
<label className="font-medium text-gray-700">Role</label>
|
<Label htmlFor="role">Role</Label>
|
||||||
<select
|
<Select value={role} onValueChange={setRole}>
|
||||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500 bg-white"
|
<SelectTrigger>
|
||||||
value={role}
|
<SelectValue placeholder="Select role" />
|
||||||
onChange={(e) => setRole(e.target.value)}
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="user">User</option>
|
<SelectItem value="USER">User</SelectItem>
|
||||||
<option value="admin">Admin</option>
|
<SelectItem value="ADMIN">Admin</SelectItem>
|
||||||
<option value="auditor">Auditor</option>
|
<SelectItem value="AUDITOR">Auditor</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<Button type="submit" className="gap-2">
|
||||||
type="submit"
|
<UserPlus className="h-4 w-4" />
|
||||||
className="bg-sky-600 hover:bg-sky-700 text-white py-2 px-4 rounded-lg shadow transition-colors"
|
|
||||||
>
|
|
||||||
Invite User
|
Invite User
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div>
|
{/* Current Users */}
|
||||||
<h2 className="text-lg font-semibold mb-4">Current Users</h2>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Current Users ({users?.length || 0})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<Table>
|
||||||
<thead className="bg-gray-50">
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
<th
|
<TableHead>Email</TableHead>
|
||||||
scope="col"
|
<TableHead>Role</TableHead>
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
<TableHead>Actions</TableHead>
|
||||||
>
|
</TableRow>
|
||||||
Email
|
</TableHeader>
|
||||||
</th>
|
<TableBody>
|
||||||
<th
|
{users.length === 0 ? (
|
||||||
scope="col"
|
<TableRow>
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
<TableCell
|
||||||
>
|
|
||||||
Role
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{loading ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={3}
|
colSpan={3}
|
||||||
className="px-6 py-4 text-center text-sm text-gray-500"
|
className="text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
Loading users...
|
No users found
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
) : users.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={3}
|
|
||||||
className="px-6 py-4 text-center text-sm text-gray-500"
|
|
||||||
>
|
|
||||||
{message || "No users found"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
) : (
|
||||||
users.map((user) => (
|
users.map((user) => (
|
||||||
<tr key={user.id}>
|
<TableRow key={user.id}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<TableCell className="font-medium">
|
||||||
{user.email}
|
{user.email}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<TableCell>
|
||||||
<span
|
<Badge
|
||||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
variant={
|
||||||
user.role === "admin"
|
user.role === "ADMIN"
|
||||||
? "bg-purple-100 text-purple-800"
|
? "default"
|
||||||
: user.role === "auditor"
|
: user.role === "AUDITOR"
|
||||||
? "bg-blue-100 text-blue-800"
|
? "secondary"
|
||||||
: "bg-green-100 text-green-800"
|
: "outline"
|
||||||
}`}
|
}
|
||||||
|
className="gap-1"
|
||||||
|
data-testid="role-badge"
|
||||||
>
|
>
|
||||||
|
{user.role === "ADMIN" && (
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{user.role === "AUDITOR" && (
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
)}
|
||||||
{user.role}
|
{user.role}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<TableCell>
|
||||||
{/* For future: Add actions like edit, delete, etc. */}
|
<span className="text-muted-foreground text-sm">
|
||||||
<span className="text-gray-400">
|
|
||||||
No actions available
|
No actions available
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
198
app/globals.css
198
app/globals.css
@ -1 +1,199 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--animate-shine: shine var(--duration) infinite linear;
|
||||||
|
@keyframes shine {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 0%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 100%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: 0% 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
--animate-meteor: meteor 5s linear infinite;
|
||||||
|
@keyframes meteor {
|
||||||
|
0% {
|
||||||
|
transform: rotate(var(--angle)) translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(var(--angle)) translateX(-500px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
--animate-background-position-spin: background-position-spin 3000ms infinite
|
||||||
|
alternate;
|
||||||
|
@keyframes background-position-spin {
|
||||||
|
0% {
|
||||||
|
background-position: top center;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: bottom center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
--animate-aurora: aurora 8s ease-in-out infinite alternate;
|
||||||
|
@keyframes aurora {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
transform: rotate(-5deg) scale(0.9);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
background-position: 50% 100%;
|
||||||
|
transform: rotate(5deg) scale(1.1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
transform: rotate(-3deg) scale(0.95);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
background-position: 50% 0%;
|
||||||
|
transform: rotate(3deg) scale(1.05);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
transform: rotate(-5deg) scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
--animate-shiny-text: shiny-text 8s infinite;
|
||||||
|
@keyframes shiny-text {
|
||||||
|
0%,
|
||||||
|
90%,
|
||||||
|
100% {
|
||||||
|
background-position: calc(-100% - var(--shiny-width)) 0;
|
||||||
|
}
|
||||||
|
30%,
|
||||||
|
60% {
|
||||||
|
background-position: calc(100% + var(--shiny-width)) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.5 0.2 240);
|
||||||
|
--primary-foreground: oklch(0.98 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.45 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.15 0 0);
|
||||||
|
--destructive: oklch(0.55 0.245 27.325);
|
||||||
|
--border: oklch(0.85 0 0);
|
||||||
|
--input: oklch(0.85 0 0);
|
||||||
|
--ring: oklch(0.6 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.7 0.2 240);
|
||||||
|
--primary-foreground: oklch(0.15 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.75 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.75 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 25%);
|
||||||
|
--input: oklch(1 0 0 / 30%);
|
||||||
|
--ring: oklch(0.75 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50 selection:bg-primary/30 selection:text-primary-foreground;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line clamp utility */
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
128
app/layout.tsx
128
app/layout.tsx
@ -1,12 +1,79 @@
|
|||||||
// Main app layout with basic global style
|
// Main app layout with basic global style
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
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. Turn every conversation into competitive intelligence.",
|
||||||
|
keywords: [
|
||||||
|
"customer analytics",
|
||||||
|
"AI sentiment analysis",
|
||||||
|
"conversation intelligence",
|
||||||
|
"customer support analytics",
|
||||||
|
"chat analytics",
|
||||||
|
"customer insights",
|
||||||
|
"conversation analytics",
|
||||||
|
"customer experience analytics",
|
||||||
|
"sentiment tracking",
|
||||||
|
"AI customer intelligence",
|
||||||
|
"automated categorization",
|
||||||
|
"real-time analytics",
|
||||||
|
"customer conversation dashboard",
|
||||||
|
],
|
||||||
|
authors: [{ name: "Notso AI" }],
|
||||||
|
creator: "Notso AI",
|
||||||
|
publisher: "Notso AI",
|
||||||
|
formatDetection: {
|
||||||
|
email: false,
|
||||||
|
address: false,
|
||||||
|
telephone: false,
|
||||||
|
},
|
||||||
|
metadataBase: new URL(
|
||||||
|
process.env.NEXTAUTH_URL || "https://livedash.notso.ai"
|
||||||
|
),
|
||||||
|
alternates: {
|
||||||
|
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.",
|
||||||
|
type: "website",
|
||||||
|
siteName: "LiveDash",
|
||||||
|
url: "/",
|
||||||
|
locale: "en_US",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/og-image.png",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
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.",
|
||||||
|
creator: "@notsoai",
|
||||||
|
site: "@notsoai",
|
||||||
|
images: ["/og-image.png"],
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
"max-video-preview": -1,
|
||||||
|
"max-image-preview": "large",
|
||||||
|
"max-snippet": -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: "/favicon.ico", sizes: "32x32", type: "image/x-icon" },
|
{ url: "/favicon.ico", sizes: "32x32", type: "image/x-icon" },
|
||||||
@ -15,13 +82,64 @@ export const metadata = {
|
|||||||
apple: "/icon-192.svg",
|
apple: "/icon-192.svg",
|
||||||
},
|
},
|
||||||
manifest: "/manifest.json",
|
manifest: "/manifest.json",
|
||||||
|
other: {
|
||||||
|
"msapplication-TileColor": "#2563eb",
|
||||||
|
"theme-color": "#ffffff",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
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",
|
||||||
|
author: {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "Notso AI",
|
||||||
|
},
|
||||||
|
applicationCategory: "Business Analytics Software",
|
||||||
|
operatingSystem: "Web Browser",
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
category: "SaaS",
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
"@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",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className="bg-gray-100 min-h-screen font-sans">
|
<head>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: Safe use for JSON-LD structured data
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="bg-background text-foreground min-h-screen font-sans antialiased">
|
||||||
|
{/* Skip navigation link for keyboard users */}
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,59 +1,270 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState } from "react";
|
import { BarChart3, Loader2, Shield, Zap } from "lucide-react";
|
||||||
import { signIn } from "next-auth/react";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
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,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const emailId = useId();
|
||||||
|
const emailHelpId = useId();
|
||||||
|
const passwordId = useId();
|
||||||
|
const passwordHelpId = useId();
|
||||||
|
const loadingStatusId = useId();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function handleLogin(e: React.FormEvent) {
|
async function handleLogin(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await signIn("credentials", {
|
const res = await signIn("credentials", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
if (res?.ok) router.push("/dashboard");
|
|
||||||
else setError("Invalid credentials.");
|
if (res?.ok) {
|
||||||
|
toast.success("Login successful! Redirecting...");
|
||||||
|
router.push("/dashboard");
|
||||||
|
} else {
|
||||||
|
setError("Invalid email or password. Please try again.");
|
||||||
|
toast.error("Login failed. Please check your credentials.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("An error occurred. Please try again.");
|
||||||
|
toast.error("An unexpected error occurred.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto mt-24 bg-white rounded-xl p-8 shadow">
|
<div className="min-h-screen flex">
|
||||||
<h1 className="text-2xl font-bold mb-6">Login</h1>
|
{/* Left side - Branding and Features */}
|
||||||
{error && <div className="text-red-600 mb-3">{error}</div>}
|
<div className="hidden lg:flex lg:flex-1 bg-linear-to-br from-primary/10 via-primary/5 to-background relative overflow-hidden">
|
||||||
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
<div className="absolute inset-0 bg-linear-to-br from-primary/5 to-transparent" />
|
||||||
<input
|
<div className="absolute -top-24 -left-24 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
|
||||||
className="border px-3 py-2 rounded"
|
<div className="absolute -bottom-24 -right-24 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
|
||||||
|
|
||||||
|
<div className="relative flex flex-col justify-center px-12 py-24">
|
||||||
|
<div className="max-w-md">
|
||||||
|
<Link href="/" className="flex items-center gap-3 mb-8">
|
||||||
|
<div className="relative w-12 h-12">
|
||||||
|
<Image
|
||||||
|
src="/favicon.svg"
|
||||||
|
alt="LiveDash Logo"
|
||||||
|
fill
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-primary">LiveDash</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight mb-6">
|
||||||
|
Welcome back to your analytics dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-muted-foreground mb-8">
|
||||||
|
Monitor, analyze, and optimize your customer conversations with
|
||||||
|
AI-powered insights.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Real-time analytics and insights
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-green-500/10 text-green-600">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Enterprise-grade security
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-600">
|
||||||
|
<Zap className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
AI-powered conversation analysis
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Login Form */}
|
||||||
|
<div className="flex-1 flex flex-col justify-center px-8 py-12 lg:px-12">
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-sm">
|
||||||
|
{/* Mobile logo */}
|
||||||
|
<div className="lg:hidden flex justify-center mb-8">
|
||||||
|
<Link href="/" className="flex items-center gap-3">
|
||||||
|
<div className="relative w-10 h-10">
|
||||||
|
<Image
|
||||||
|
src="/favicon.svg"
|
||||||
|
alt="LiveDash Logo"
|
||||||
|
fill
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-primary">LiveDash</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-border/50 shadow-xl">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl font-bold">Sign in</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email and password to access your dashboard
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Live region for screen reader announcements */}
|
||||||
|
<output aria-live="polite" className="sr-only">
|
||||||
|
{isLoading && "Signing in, please wait..."}
|
||||||
|
{error && `Error: ${error}`}
|
||||||
|
</output>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mb-6" role="alert">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4" noValidate>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={emailId}>Email</Label>
|
||||||
|
<Input
|
||||||
|
id={emailId}
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Email"
|
placeholder="name@company.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
required
|
required
|
||||||
|
aria-describedby={emailHelpId}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
|
||||||
/>
|
/>
|
||||||
<input
|
<div id={emailHelpId} className="sr-only">
|
||||||
className="border px-3 py-2 rounded"
|
Enter your company email address
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={passwordId}>Password</Label>
|
||||||
|
<Input
|
||||||
|
id={passwordId}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
placeholder="Enter your password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
required
|
required
|
||||||
|
aria-describedby={passwordHelpId}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
|
||||||
/>
|
/>
|
||||||
<button className="bg-blue-600 text-white rounded py-2" type="submit">
|
<div id={passwordHelpId} className="sr-only">
|
||||||
Login
|
Enter your account password
|
||||||
</button>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
<div className="mt-4 text-center">
|
|
||||||
<a href="/register" className="text-blue-600 underline">
|
<Button
|
||||||
Register company
|
type="submit"
|
||||||
</a>
|
className="w-full mt-6 h-11 bg-linear-to-r from-primary to-primary/90 hover:from-primary/90 hover:to-primary/80 transition-all duration-200"
|
||||||
|
disabled={isLoading || !email || !password}
|
||||||
|
aria-describedby={isLoading ? "loading-status" : undefined}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2
|
||||||
|
className="mr-2 h-4 w-4 animate-spin"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Signing in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Sign in"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{isLoading && (
|
||||||
|
<div
|
||||||
|
id={loadingStatusId}
|
||||||
|
className="sr-only"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Authentication in progress, please wait
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="text-sm text-primary hover:underline transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||||
|
>
|
||||||
|
Don't have a company account? Register here
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="mt-8 text-center text-xs text-muted-foreground">
|
||||||
|
By signing in, you agree to our{" "}
|
||||||
|
<Link
|
||||||
|
href="/terms"
|
||||||
|
className="text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-center">
|
|
||||||
<a href="/forgot-password" className="text-blue-600 underline">
|
|
||||||
Forgot password?
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
470
app/page.tsx
470
app/page.tsx
@ -1,8 +1,466 @@
|
|||||||
import { auth } from "../auth";
|
"use client";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default async function HomePage() {
|
import {
|
||||||
const session = await auth();
|
ArrowRight,
|
||||||
if (session?.user) redirect("/dashboard");
|
BarChart3,
|
||||||
else redirect("/login");
|
Brain,
|
||||||
|
Globe,
|
||||||
|
MessageCircle,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
TrendingUp,
|
||||||
|
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();
|
||||||
|
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" />
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* Smart Categorization */}
|
||||||
|
<div className="flex items-center gap-8 group">
|
||||||
|
<div className="flex-1" />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Enterprise Security */}
|
||||||
|
<div className="flex items-center gap-8 group">
|
||||||
|
<div className="flex-1" />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Global Scale */}
|
||||||
|
<div className="flex items-center gap-8 group">
|
||||||
|
<div className="flex-1" />
|
||||||
|
<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="/features"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Features
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/pricing"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Pricing
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/api" className="hover:text-white transition-colors">
|
||||||
|
API
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/integrations"
|
||||||
|
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="/about"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
About
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/blog"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Blog
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/careers"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Careers
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/contact"
|
||||||
|
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="/docs"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/help"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Help Center
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/privacy"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Privacy
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/terms"
|
||||||
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
806
app/platform/companies/[id]/page.tsx
Normal file
806
app/platform/companies/[id]/page.tsx
Normal file
@ -0,0 +1,806 @@
|
|||||||
|
"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 { useCallback, useEffect, useId, useState } from "react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
invitedBy: string | null;
|
||||||
|
invitedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Company {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
status: string;
|
||||||
|
maxUsers: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
users: User[];
|
||||||
|
_count: {
|
||||||
|
sessions: number;
|
||||||
|
imports: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompanyManagement() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const companyNameFieldId = useId();
|
||||||
|
const companyEmailFieldId = useId();
|
||||||
|
const maxUsersFieldId = useId();
|
||||||
|
const inviteNameFieldId = useId();
|
||||||
|
const inviteEmailFieldId = useId();
|
||||||
|
|
||||||
|
const fetchCompany = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/platform/companies/${params.id}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCompany(data);
|
||||||
|
const companyData = {
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
status: data.status,
|
||||||
|
maxUsers: data.maxUsers,
|
||||||
|
};
|
||||||
|
setEditData(companyData);
|
||||||
|
setOriginalData(companyData);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load company data",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch company:", error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load company data",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [params.id, toast]);
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Function to check if data has been modified
|
||||||
|
const hasUnsavedChanges = useCallback(() => {
|
||||||
|
// Normalize data for comparison (handle null/undefined/empty string equivalence)
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}, [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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
if (!session?.user?.isPlatformUser) {
|
||||||
|
router.push("/platform/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCompany();
|
||||||
|
}, [session, status, router, fetchCompany]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/platform/companies/${params.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(editData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const updatedCompany = await response.json();
|
||||||
|
setCompany(updatedCompany);
|
||||||
|
const companyData = {
|
||||||
|
name: updatedCompany.name,
|
||||||
|
email: updatedCompany.email,
|
||||||
|
status: updatedCompany.status,
|
||||||
|
maxUsers: updatedCompany.maxUsers,
|
||||||
|
};
|
||||||
|
setOriginalData(companyData);
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Company updated successfully",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to update company");
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to update company",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (newStatus: string) => {
|
||||||
|
const statusAction = newStatus === "SUSPENDED" ? "suspend" : "activate";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/platform/companies/${params.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: newStatus }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setCompany((prev) => (prev ? { ...prev, status: newStatus } : null));
|
||||||
|
setEditData((prev) => ({ ...prev, status: newStatus }));
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: `Company ${statusAction}d successfully`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(`Failed to ${statusAction} company`);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `Failed to ${statusAction} company`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmNavigation = () => {
|
||||||
|
if (pendingNavigation) {
|
||||||
|
router.push(pendingNavigation);
|
||||||
|
setPendingNavigation(null);
|
||||||
|
}
|
||||||
|
setShowUnsavedChangesDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelNavigation = () => {
|
||||||
|
setPendingNavigation(null);
|
||||||
|
setShowUnsavedChangesDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Protect against browser back/forward and other navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (hasUnsavedChanges()) {
|
||||||
|
e.preventDefault();
|
||||||
|
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?"
|
||||||
|
);
|
||||||
|
if (!confirmLeave) {
|
||||||
|
// Push the current state back to prevent navigation
|
||||||
|
window.history.pushState(null, "", window.location.href);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
window.addEventListener("popstate", handlePopState);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setShowInviteUser(false);
|
||||||
|
setInviteData({ name: "", email: "", role: "USER" });
|
||||||
|
fetchCompany(); // Refresh company data
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "User invited successfully",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to invite user");
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to invite user",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 company details...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user?.isPlatformUser || !company) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canEdit = session.user.platformRole === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
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 className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleNavigation("/platform/dashboard")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{company.name}
|
||||||
|
</h1>
|
||||||
|
<Badge variant={getStatusBadgeVariant(company.status)}>
|
||||||
|
{company.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Company Management
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowInviteUser(true)}
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
Invite User
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<Tabs defaultValue="overview" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="users">Users</TabsTrigger>
|
||||||
|
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||||
|
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="space-y-6">
|
||||||
|
{/* Stats Overview */}
|
||||||
|
<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>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{company.users.length}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
of {company.maxUsers} maximum
|
||||||
|
</p>
|
||||||
|
</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">
|
||||||
|
{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>
|
||||||
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{company._count.imports}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Created</CardTitle>
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-sm font-bold">
|
||||||
|
{new Date(company.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company Info */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Company Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={companyNameFieldId}>Company Name</Label>
|
||||||
|
<Input
|
||||||
|
id={companyNameFieldId}
|
||||||
|
value={editData.name || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={companyEmailFieldId}>Contact Email</Label>
|
||||||
|
<Input
|
||||||
|
id={companyEmailFieldId}
|
||||||
|
type="email"
|
||||||
|
value={editData.email || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
email: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={maxUsersFieldId}>Max Users</Label>
|
||||||
|
<Input
|
||||||
|
id={maxUsersFieldId}
|
||||||
|
type="number"
|
||||||
|
value={editData.maxUsers || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
maxUsers: Number.parseInt(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="status">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={editData.status}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setEditData((prev) => ({ ...prev, status: value }))
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||||
|
<SelectItem value="TRIAL">Trial</SelectItem>
|
||||||
|
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
||||||
|
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canEdit && hasUnsavedChanges() && (
|
||||||
|
<div className="flex gap-2 pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setEditData(originalData);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel Changes
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{isSaving ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="users" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
Users ({company.users.length})
|
||||||
|
</span>
|
||||||
|
{canEdit && (
|
||||||
|
<Button size="sm" onClick={() => setShowInviteUser(true)}>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
Invite User
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{company.users.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg"
|
||||||
|
>
|
||||||
|
<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()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<Badge variant="outline">{user.role}</Badge>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Joined {new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{company.users.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No users found. Invite the first user to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="settings" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-red-600 dark:text-red-400">
|
||||||
|
Danger Zone
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between p-4 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Suspend Company</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Temporarily disable access to this company
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<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.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleStatusChange("SUSPENDED")}
|
||||||
|
>
|
||||||
|
Suspend
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{company.status === "SUSPENDED" && (
|
||||||
|
<div className="flex items-center justify-between p-4 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Reactivate Company</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Restore access to this company
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => handleStatusChange("ACTIVE")}
|
||||||
|
>
|
||||||
|
Reactivate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="analytics" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Analytics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Analytics dashboard coming soon...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite User Dialog */}
|
||||||
|
{showInviteUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<Card className="w-full max-w-md mx-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Invite User</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={inviteNameFieldId}>Name</Label>
|
||||||
|
<Input
|
||||||
|
id={inviteNameFieldId}
|
||||||
|
value={inviteData.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInviteData((prev) => ({ ...prev, name: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="User's full name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={inviteEmailFieldId}>Email</Label>
|
||||||
|
<Input
|
||||||
|
id={inviteEmailFieldId}
|
||||||
|
type="email"
|
||||||
|
value={inviteData.email}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInviteData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
email: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="inviteRole">Role</Label>
|
||||||
|
<Select
|
||||||
|
value={inviteData.role}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setInviteData((prev) => ({ ...prev, role: value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="USER">User</SelectItem>
|
||||||
|
<SelectItem value="ADMIN">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowInviteUser(false)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleInviteUser}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!inviteData.email || !inviteData.name}
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
|
Send Invite
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unsaved Changes Dialog */}
|
||||||
|
<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?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={cancelNavigation}>
|
||||||
|
Stay on Page
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmNavigation}>
|
||||||
|
Leave Without Saving
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
670
app/platform/dashboard/page.tsx
Normal file
670
app/platform/dashboard/page.tsx
Normal file
@ -0,0 +1,670 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
BarChart3,
|
||||||
|
Building2,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Database,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useId, 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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlatformSession {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
isPlatformUser: boolean;
|
||||||
|
platformRole: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom hook for platform session
|
||||||
|
function usePlatformSession() {
|
||||||
|
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");
|
||||||
|
} else {
|
||||||
|
setSession(null);
|
||||||
|
setStatus("unauthenticated");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Platform session fetch error:", error);
|
||||||
|
setSession(null);
|
||||||
|
setStatus("unauthenticated");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data: session, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlatformDashboard() {
|
||||||
|
const { data: session, status } = usePlatformSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [dashboardData, setDashboardData] = useState<DashboardData | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showAddCompany, setShowAddCompany] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [copiedEmail, setCopiedEmail] = useState(false);
|
||||||
|
const [copiedPassword, setCopiedPassword] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [newCompanyData, setNewCompanyData] = useState({
|
||||||
|
name: "",
|
||||||
|
csvUrl: "",
|
||||||
|
csvUsername: "",
|
||||||
|
csvPassword: "",
|
||||||
|
adminEmail: "",
|
||||||
|
adminName: "",
|
||||||
|
adminPassword: "",
|
||||||
|
maxUsers: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const companyNameId = useId();
|
||||||
|
const csvUrlId = useId();
|
||||||
|
const csvUsernameId = useId();
|
||||||
|
const csvPasswordId = useId();
|
||||||
|
const adminNameId = useId();
|
||||||
|
const adminEmailId = useId();
|
||||||
|
const adminPasswordId = useId();
|
||||||
|
const maxUsersId = useId();
|
||||||
|
|
||||||
|
const fetchDashboardData = useCallback(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);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "loading") return;
|
||||||
|
|
||||||
|
if (status === "unauthenticated" || !session?.user?.isPlatformUser) {
|
||||||
|
router.push("/platform/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDashboardData();
|
||||||
|
}, [session, status, router, fetchDashboardData]);
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, type: "email" | "password") => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
if (type === "email") {
|
||||||
|
setCopiedEmail(true);
|
||||||
|
setTimeout(() => setCopiedEmail(false), 2000);
|
||||||
|
} else {
|
||||||
|
setCopiedPassword(true);
|
||||||
|
setTimeout(() => setCopiedPassword(false), 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy: ", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilteredCompanies = () => {
|
||||||
|
if (!dashboardData?.companies) return [];
|
||||||
|
|
||||||
|
return dashboardData.companies.filter((company) =>
|
||||||
|
company.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateCompany = async () => {
|
||||||
|
if (
|
||||||
|
!newCompanyData.name ||
|
||||||
|
!newCompanyData.csvUrl ||
|
||||||
|
!newCompanyData.adminEmail ||
|
||||||
|
!newCompanyData.adminName
|
||||||
|
) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Please fill in all required fields",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/platform/companies", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(newCompanyData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
setShowAddCompany(false);
|
||||||
|
|
||||||
|
const companyName = newCompanyData.name;
|
||||||
|
setNewCompanyData({
|
||||||
|
name: "",
|
||||||
|
csvUrl: "",
|
||||||
|
csvUsername: "",
|
||||||
|
csvPassword: "",
|
||||||
|
adminEmail: "",
|
||||||
|
adminName: "",
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
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" />
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
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" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
duration: 15000, // Longer duration for credentials
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: `Company "${companyName}" created successfully`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || "Failed to create company");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "Failed to create company",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCreating(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 (status === "unauthenticated" || !session?.user?.isPlatformUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 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" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search companies..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10 w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Settings
|
||||||
|
</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 justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-5 h-5" />
|
||||||
|
Companies
|
||||||
|
{searchTerm && (
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
{filteredCompanies.length} of {totalCompanies} shown
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{searchTerm && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Search: "{searchTerm}"
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Dialog open={showAddCompany} onOpenChange={setShowAddCompany}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Company
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New Company</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new company and invite the first administrator.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={companyNameId}>Company Name *</Label>
|
||||||
|
<Input
|
||||||
|
id={companyNameId}
|
||||||
|
value={newCompanyData.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewCompanyData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Acme Corporation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={csvUrlId}>CSV Data URL *</Label>
|
||||||
|
<Input
|
||||||
|
id={csvUrlId}
|
||||||
|
value={newCompanyData.csvUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewCompanyData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
csvUrl: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="https://api.company.com/sessions.csv"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={csvUsernameId}>CSV Auth Username</Label>
|
||||||
|
<Input
|
||||||
|
id={csvUsernameId}
|
||||||
|
value={newCompanyData.csvUsername}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewCompanyData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
csvUsername: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Optional HTTP auth username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={csvPasswordId}>CSV Auth Password</Label>
|
||||||
|
<Input
|
||||||
|
id={csvPasswordId}
|
||||||
|
type="password"
|
||||||
|
value={newCompanyData.csvPassword}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewCompanyData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
csvPassword: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Optional HTTP auth password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={adminNameId}>Admin Name *</Label>
|
||||||
|
<Input
|
||||||
|
id={adminNameId}
|
||||||
|
value={newCompanyData.adminName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewCompanyData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
adminName: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={adminEmailId}>Admin Email *</Label>
|
||||||
|
<Input
|
||||||
|
id={adminEmailId}
|
||||||
|
type="email"
|
||||||
|
value={newCompanyData.adminEmail}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewCompanyData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
adminEmail: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="admin@acme.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={adminPasswordId}>Admin Password</Label>
|
||||||
|
<Input
|
||||||
|
id={adminPasswordId}
|
||||||
|
type="password"
|
||||||
|
value={newCompanyData.adminPassword}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewCompanyData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
adminPassword: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Leave empty to auto-generate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={maxUsersId}>Max Users</Label>
|
||||||
|
<Input
|
||||||
|
id={maxUsersId}
|
||||||
|
type="number"
|
||||||
|
value={newCompanyData.maxUsers}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewCompanyData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
maxUsers: Number.parseInt(e.target.value) || 10,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
min="1"
|
||||||
|
max="1000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowAddCompany(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateCompany}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
{isCreating ? "Creating..." : "Create Company"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredCompanies.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"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/platform/companies/${company.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!filteredCompanies.length && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{searchTerm ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>No companies match "{searchTerm}".</p>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={() => setSearchTerm("")}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
Clear search to see all companies
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"No companies found. Create your first company to get started."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
app/platform/layout.tsx
Normal file
25
app/platform/layout.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
|
||||||
|
export default function PlatformLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<SessionProvider basePath="/api/platform/auth">
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</SessionProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
app/platform/login/page.tsx
Normal file
102
app/platform/login/page.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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 { 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);
|
||||||
|
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 if (result?.ok) {
|
||||||
|
// Login successful, redirect to dashboard
|
||||||
|
router.push("/platform/dashboard");
|
||||||
|
}
|
||||||
|
} 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 relative">
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<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={emailId}>Email</Label>
|
||||||
|
<Input
|
||||||
|
id={emailId}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={passwordId}>Password</Label>
|
||||||
|
<Input
|
||||||
|
id={passwordId}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? "Signing in..." : "Sign In"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
app/platform/page.tsx
Normal file
23
app/platform/page.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export default function PlatformIndexPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Redirect to platform dashboard
|
||||||
|
router.replace("/platform/dashboard");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,17 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
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 }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
// Including error handling and refetch interval for better user experience
|
// Including error handling and refetch interval for better user experience
|
||||||
return (
|
return (
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
<SessionProvider
|
<SessionProvider
|
||||||
// Re-fetch session every 10 minutes
|
// Re-fetch session every 30 minutes (reduced from 10)
|
||||||
refetchInterval={10 * 60}
|
refetchInterval={30 * 60}
|
||||||
refetchOnWindowFocus={true}
|
refetchOnWindowFocus={false}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [email, setEmail] = useState<string>("");
|
const [email, setEmail] = useState<string>("");
|
||||||
const [company, setCompany] = useState<string>("");
|
const [company, setCompany] = useState<string>("");
|
||||||
const [password, setPassword] = useState<string>("");
|
const [password, setPassword] = useState<string>("");
|
||||||
const [csvUrl, setCsvUrl] = useState<string>("");
|
const [csvUrl, setCsvUrl] = useState<string>("");
|
||||||
const [role, setRole] = useState<string>("admin"); // Default to admin for company registration
|
const [role, setRole] = useState<string>("ADMIN"); // Default to ADMIN for company registration
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ export default function RegisterPage() {
|
|||||||
>
|
>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
<option value="user">User</option>
|
<option value="user">User</option>
|
||||||
<option value="auditor">Auditor</option>
|
<option value="AUDITOR">Auditor</option>
|
||||||
</select>
|
</select>
|
||||||
<button className="bg-blue-600 text-white rounded py-2" type="submit">
|
<button className="bg-blue-600 text-white rounded py-2" type="submit">
|
||||||
Register & Continue
|
Register & Continue
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState, Suspense } from "react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
|
||||||
// Component that uses useSearchParams wrapped in Suspense
|
// Component that uses useSearchParams wrapped in Suspense
|
||||||
function ResetPasswordForm() {
|
function ResetPasswordForm() {
|
||||||
|
|||||||
117
auth.ts
117
auth.ts
@ -1,117 +0,0 @@
|
|||||||
import NextAuth, { NextAuthConfig } from "next-auth";
|
|
||||||
import { D1Adapter } from "@auth/d1-adapter";
|
|
||||||
import Credentials from "next-auth/providers/credentials";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { PrismaD1 } from "@prisma/adapter-d1";
|
|
||||||
|
|
||||||
// Check if we're in a Cloudflare Workers environment
|
|
||||||
const isCloudflareWorker =
|
|
||||||
typeof globalThis.caches !== "undefined" &&
|
|
||||||
typeof (globalThis as any).WebSocketPair !== "undefined";
|
|
||||||
|
|
||||||
// For local development, we'll use the same D1 database that wrangler creates
|
|
||||||
const isDevelopment = process.env.NODE_ENV === "development";
|
|
||||||
|
|
||||||
const config: NextAuthConfig = {
|
|
||||||
providers: [
|
|
||||||
Credentials({
|
|
||||||
name: "credentials",
|
|
||||||
credentials: {
|
|
||||||
email: { label: "Email", type: "email" },
|
|
||||||
password: { label: "Password", type: "password" },
|
|
||||||
},
|
|
||||||
authorize: async (credentials) => {
|
|
||||||
if (!credentials?.email || !credentials?.password) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
|
|
||||||
// Initialize Prisma based on environment
|
|
||||||
if (isCloudflareWorker) {
|
|
||||||
// In Cloudflare Workers (production), get DB from bindings
|
|
||||||
const adapter = new PrismaD1((globalThis as any).DB);
|
|
||||||
prisma = new PrismaClient({ adapter });
|
|
||||||
} else {
|
|
||||||
// In local development (Next.js), use the local D1 database
|
|
||||||
// This uses the same database that wrangler creates locally
|
|
||||||
prisma = new PrismaClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: credentials.email as string },
|
|
||||||
include: { company: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await bcrypt.compare(
|
|
||||||
credentials.password as string,
|
|
||||||
user.password
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!valid) {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.email, // Use email as name
|
|
||||||
role: user.role,
|
|
||||||
companyId: user.companyId,
|
|
||||||
company: user.company.name,
|
|
||||||
};
|
|
||||||
|
|
||||||
await prisma.$disconnect();
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Authentication error:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
callbacks: {
|
|
||||||
jwt: async ({ token, user }: any) => {
|
|
||||||
if (user) {
|
|
||||||
token.role = user.role;
|
|
||||||
token.companyId = user.companyId;
|
|
||||||
token.company = user.company;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
session: async ({ session, token }: any) => {
|
|
||||||
if (token && session.user) {
|
|
||||||
session.user.id = token.sub;
|
|
||||||
session.user.role = token.role;
|
|
||||||
session.user.companyId = token.companyId;
|
|
||||||
session.user.company = token.company;
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pages: {
|
|
||||||
signIn: "/login",
|
|
||||||
error: "/login",
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
strategy: "jwt",
|
|
||||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
||||||
},
|
|
||||||
secret: process.env.AUTH_SECRET,
|
|
||||||
trustHost: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add D1 adapter only in Cloudflare Workers environment
|
|
||||||
if (isCloudflareWorker && (globalThis as any).DB) {
|
|
||||||
(config as any).adapter = D1Adapter((globalThis as any).DB);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { auth, signIn, signOut } = NextAuth(config);
|
|
||||||
74
biome.json
Normal file
74
biome.json
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedVariables": "error",
|
||||||
|
"noUnusedImports": "error"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"useConst": "error",
|
||||||
|
"useTemplate": "error",
|
||||||
|
"noParameterAssign": "error",
|
||||||
|
"useAsConstAssertion": "error",
|
||||||
|
"useDefaultParameterLast": "error",
|
||||||
|
"useEnumInitializers": "error",
|
||||||
|
"useSelfClosingElements": "error",
|
||||||
|
"useSingleVarDeclarator": "error",
|
||||||
|
"noUnusedTemplateLiteral": "error",
|
||||||
|
"useNumberNamespace": "error",
|
||||||
|
"noInferrableTypes": "error",
|
||||||
|
"noUselessElse": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "warn",
|
||||||
|
"noArrayIndexKey": "warn"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"noForEach": "off",
|
||||||
|
"noExcessiveCognitiveComplexity": {
|
||||||
|
"level": "error",
|
||||||
|
"options": { "maxAllowedComplexity": 15 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"formatWithErrors": false,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineEnding": "lf",
|
||||||
|
"lineWidth": 80
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"jsxQuoteStyle": "double",
|
||||||
|
"quoteProperties": "asNeeded",
|
||||||
|
"trailingCommas": "es5",
|
||||||
|
"semicolons": "always",
|
||||||
|
"arrowParentheses": "always",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"quoteStyle": "double"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": [
|
||||||
|
"app/**",
|
||||||
|
"lib/**",
|
||||||
|
"components/**",
|
||||||
|
"*.ts",
|
||||||
|
"*.tsx",
|
||||||
|
"*.js",
|
||||||
|
"*.jsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
93
check-refactored-pipeline-status.ts
Normal file
93
check-refactored-pipeline-status.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { ProcessingStatusManager } from "./lib/processingStatusManager";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function checkRefactoredPipelineStatus() {
|
||||||
|
try {
|
||||||
|
console.log("=== REFACTORED PIPELINE STATUS ===\n");
|
||||||
|
|
||||||
|
// Get pipeline status using the new system
|
||||||
|
const pipelineStatus = await ProcessingStatusManager.getPipelineStatus();
|
||||||
|
|
||||||
|
console.log(`Total Sessions: ${pipelineStatus.totalSessions}\n`);
|
||||||
|
|
||||||
|
// Display status for each stage
|
||||||
|
const stages = [
|
||||||
|
"CSV_IMPORT",
|
||||||
|
"TRANSCRIPT_FETCH",
|
||||||
|
"SESSION_CREATION",
|
||||||
|
"AI_ANALYSIS",
|
||||||
|
"QUESTION_EXTRACTION",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const stage of stages) {
|
||||||
|
console.log(`${stage}:`);
|
||||||
|
const stageData = pipelineStatus.pipeline[stage] || {};
|
||||||
|
|
||||||
|
const pending = stageData.PENDING || 0;
|
||||||
|
const inProgress = stageData.IN_PROGRESS || 0;
|
||||||
|
const completed = stageData.COMPLETED || 0;
|
||||||
|
const failed = stageData.FAILED || 0;
|
||||||
|
const skipped = stageData.SKIPPED || 0;
|
||||||
|
|
||||||
|
console.log(` PENDING: ${pending}`);
|
||||||
|
console.log(` IN_PROGRESS: ${inProgress}`);
|
||||||
|
console.log(` COMPLETED: ${completed}`);
|
||||||
|
console.log(` FAILED: ${failed}`);
|
||||||
|
console.log(` SKIPPED: ${skipped}`);
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show what needs processing
|
||||||
|
console.log("=== WHAT NEEDS PROCESSING ===");
|
||||||
|
|
||||||
|
for (const stage of stages) {
|
||||||
|
const stageData = pipelineStatus.pipeline[stage] || {};
|
||||||
|
const pending = stageData.PENDING || 0;
|
||||||
|
const failed = stageData.FAILED || 0;
|
||||||
|
|
||||||
|
if (pending > 0 || failed > 0) {
|
||||||
|
console.log(`• ${stage}: ${pending} pending, ${failed} failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show failed sessions if any
|
||||||
|
const failedSessions = await ProcessingStatusManager.getFailedSessions();
|
||||||
|
if (failedSessions.length > 0) {
|
||||||
|
console.log("\n=== FAILED SESSIONS ===");
|
||||||
|
failedSessions.slice(0, 5).forEach((failure) => {
|
||||||
|
console.log(
|
||||||
|
` ${failure.session.import?.externalSessionId || failure.sessionId}: ${failure.stage} - ${failure.errorMessage}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failedSessions.length > 5) {
|
||||||
|
console.log(
|
||||||
|
` ... and ${failedSessions.length - 5} more failed sessions`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show sessions ready for AI processing
|
||||||
|
const readyForAI =
|
||||||
|
await ProcessingStatusManager.getSessionsNeedingProcessing(
|
||||||
|
"AI_ANALYSIS",
|
||||||
|
5
|
||||||
|
);
|
||||||
|
if (readyForAI.length > 0) {
|
||||||
|
console.log("\n=== SESSIONS READY FOR AI PROCESSING ===");
|
||||||
|
readyForAI.forEach((status) => {
|
||||||
|
console.log(
|
||||||
|
` ${status.session.import?.externalSessionId || status.sessionId} (created: ${status.session.createdAt})`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking pipeline status:", error);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkRefactoredPipelineStatus();
|
||||||
5765
cloudflare-env.d.ts
vendored
5765
cloudflare-env.d.ts
vendored
File diff suppressed because it is too large
Load Diff
21
components.json
Normal file
21
components.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import Chart from "chart.js/auto";
|
import Chart from "chart.js/auto";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { getLocalizedLanguageName } from "../lib/localization"; // Corrected import path
|
import { getLocalizedLanguageName } from "../lib/localization"; // Corrected import path
|
||||||
|
|
||||||
interface SessionsData {
|
interface SessionsData {
|
||||||
@ -219,7 +219,7 @@ export function LanguagePieChart({ languages }: LanguagePieChartProps) {
|
|||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function (context) {
|
label: (context) => {
|
||||||
const label = context.label || "";
|
const label = context.label || "";
|
||||||
const value = context.formattedValue || "";
|
const value = context.formattedValue || "";
|
||||||
const index = context.dataIndex;
|
const index = context.dataIndex;
|
||||||
|
|||||||
164
components/DateRangePicker.tsx
Normal file
164
components/DateRangePicker.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
|
interface DateRangePickerProps {
|
||||||
|
minDate: string;
|
||||||
|
maxDate: string;
|
||||||
|
onDateRangeChange: (startDate: string, endDate: string) => void;
|
||||||
|
initialStartDate?: string;
|
||||||
|
initialEndDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DateRangePicker({
|
||||||
|
minDate,
|
||||||
|
maxDate,
|
||||||
|
onDateRangeChange,
|
||||||
|
initialStartDate,
|
||||||
|
initialEndDate,
|
||||||
|
}: DateRangePickerProps) {
|
||||||
|
const startDateId = useId();
|
||||||
|
const endDateId = useId();
|
||||||
|
const [startDate, setStartDate] = useState(initialStartDate || minDate);
|
||||||
|
const [endDate, setEndDate] = useState(initialEndDate || maxDate);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only notify parent component when dates change, not when the callback changes
|
||||||
|
onDateRangeChange(startDate, endDate);
|
||||||
|
}, [
|
||||||
|
startDate,
|
||||||
|
endDate, // Only notify parent component when dates change, not when the callback changes
|
||||||
|
onDateRangeChange,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleStartDateChange = (newStartDate: string) => {
|
||||||
|
// Ensure start date is not before min date
|
||||||
|
if (newStartDate < minDate) {
|
||||||
|
setStartDate(minDate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure start date is not after end date
|
||||||
|
if (newStartDate > endDate) {
|
||||||
|
setEndDate(newStartDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStartDate(newStartDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndDateChange = (newEndDate: string) => {
|
||||||
|
// Ensure end date is not after max date
|
||||||
|
if (newEndDate > maxDate) {
|
||||||
|
setEndDate(maxDate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure end date is not before start date
|
||||||
|
if (newEndDate < startDate) {
|
||||||
|
setStartDate(newEndDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEndDate(newEndDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetToFullRange = () => {
|
||||||
|
setStartDate(minDate);
|
||||||
|
setEndDate(maxDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLast30Days = () => {
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
// Use the later of 30 days ago or minDate
|
||||||
|
const newStartDate =
|
||||||
|
thirtyDaysAgoStr > minDate ? thirtyDaysAgoStr : minDate;
|
||||||
|
setStartDate(newStartDate);
|
||||||
|
setEndDate(maxDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLast7Days = () => {
|
||||||
|
const sevenDaysAgo = new Date();
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
const sevenDaysAgoStr = sevenDaysAgo.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
// Use the later of 7 days ago or minDate
|
||||||
|
const newStartDate = sevenDaysAgoStr > minDate ? sevenDaysAgoStr : minDate;
|
||||||
|
setStartDate(newStartDate);
|
||||||
|
setEndDate(maxDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||||
|
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">
|
||||||
|
Date Range:
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 items-start sm:items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor={startDateId} className="text-sm text-gray-600">
|
||||||
|
From:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={startDateId}
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
min={minDate}
|
||||||
|
max={maxDate}
|
||||||
|
onChange={(e) => handleStartDateChange(e.target.value)}
|
||||||
|
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor={endDateId} className="text-sm text-gray-600">
|
||||||
|
To:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={endDateId}
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
min={minDate}
|
||||||
|
max={maxDate}
|
||||||
|
onChange={(e) => handleEndDateChange(e.target.value)}
|
||||||
|
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={setLast7Days}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-sky-600 bg-sky-50 border border-sky-200 rounded-md hover:bg-sky-100 transition-colors"
|
||||||
|
>
|
||||||
|
Last 7 days
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={setLast30Days}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-sky-600 bg-sky-50 border border-sky-200 rounded-md hover:bg-sky-100 transition-colors"
|
||||||
|
>
|
||||||
|
Last 30 days
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetToFullRange}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-50 border border-gray-200 rounded-md hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
All time
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
Available data: {new Date(minDate).toLocaleDateString()} -{" "}
|
||||||
|
{new Date(maxDate).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
import Chart, { type BubbleDataPoint, type Point } from "chart.js/auto";
|
||||||
import Chart, { Point, BubbleDataPoint } from "chart.js/auto";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface DonutChartProps {
|
interface DonutChartProps {
|
||||||
data: {
|
data: {
|
||||||
@ -73,7 +73,7 @@ export default function DonutChart({ data, centerText }: DonutChartProps) {
|
|||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function (context) {
|
label: (context) => {
|
||||||
const label = context.label || "";
|
const label = context.label || "";
|
||||||
const value = context.formattedValue;
|
const value = context.formattedValue;
|
||||||
const total = context.chart.data.datasets[0].data.reduce(
|
const total = context.chart.data.datasets[0].data.reduce(
|
||||||
@ -106,7 +106,7 @@ export default function DonutChart({ data, centerText }: DonutChartProps) {
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "centerText",
|
id: "centerText",
|
||||||
beforeDraw: function (chart: Chart<"doughnut">) {
|
beforeDraw: (chart: Chart<"doughnut">) => {
|
||||||
const height = chart.height;
|
const height = chart.height;
|
||||||
const ctx = chart.ctx;
|
const ctx = chart.ctx;
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import * as countryCoder from "@rapideditor/country-coder";
|
import * as countryCoder from "@rapideditor/country-coder";
|
||||||
|
|
||||||
@ -25,6 +25,30 @@ const getCountryCoordinates = (): Record<string, [number, number]> => {
|
|||||||
US: [37.0902, -95.7129],
|
US: [37.0902, -95.7129],
|
||||||
GB: [55.3781, -3.436],
|
GB: [55.3781, -3.436],
|
||||||
BA: [43.9159, 17.6791],
|
BA: [43.9159, 17.6791],
|
||||||
|
NL: [52.1326, 5.2913],
|
||||||
|
DE: [51.1657, 10.4515],
|
||||||
|
FR: [46.6034, 1.8883],
|
||||||
|
IT: [41.8719, 12.5674],
|
||||||
|
ES: [40.4637, -3.7492],
|
||||||
|
CA: [56.1304, -106.3468],
|
||||||
|
PL: [51.9194, 19.1451],
|
||||||
|
SE: [60.1282, 18.6435],
|
||||||
|
NO: [60.472, 8.4689],
|
||||||
|
FI: [61.9241, 25.7482],
|
||||||
|
CH: [46.8182, 8.2275],
|
||||||
|
AT: [47.5162, 14.5501],
|
||||||
|
BE: [50.8503, 4.3517],
|
||||||
|
DK: [56.2639, 9.5018],
|
||||||
|
CZ: [49.8175, 15.473],
|
||||||
|
HU: [47.1625, 19.5033],
|
||||||
|
PT: [39.3999, -8.2245],
|
||||||
|
GR: [39.0742, 21.8243],
|
||||||
|
RO: [45.9432, 24.9668],
|
||||||
|
IE: [53.4129, -8.2439],
|
||||||
|
BG: [42.7339, 25.4858],
|
||||||
|
HR: [45.1, 15.2],
|
||||||
|
SK: [48.669, 19.699],
|
||||||
|
SI: [46.1512, 14.9955],
|
||||||
};
|
};
|
||||||
// This function now primarily returns fallbacks.
|
// This function now primarily returns fallbacks.
|
||||||
// The actual fetching using @rapideditor/country-coder will be in the component's useEffect.
|
// The actual fetching using @rapideditor/country-coder will be in the component's useEffect.
|
||||||
@ -36,10 +60,10 @@ const DEFAULT_COORDINATES = getCountryCoordinates();
|
|||||||
|
|
||||||
// Dynamically import the Map component to avoid SSR issues
|
// Dynamically import the Map component to avoid SSR issues
|
||||||
// This ensures the component only loads on the client side
|
// This ensures the component only loads on the client side
|
||||||
const Map = dynamic(() => import("./Map"), {
|
const CountryMapComponent = dynamic(() => import("./Map"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
<div className="h-full w-full bg-muted flex items-center justify-center text-muted-foreground">
|
||||||
Loading map...
|
Loading map...
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -71,7 +95,7 @@ export default function GeographicMap({
|
|||||||
|
|
||||||
if (!countryCoords) {
|
if (!countryCoords) {
|
||||||
const feature = countryCoder.feature(code);
|
const feature = countryCoder.feature(code);
|
||||||
if (feature && feature.geometry) {
|
if (feature?.geometry) {
|
||||||
if (feature.geometry.type === "Point") {
|
if (feature.geometry.type === "Point") {
|
||||||
const [lon, lat] = feature.geometry.coordinates;
|
const [lon, lat] = feature.geometry.coordinates;
|
||||||
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
|
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
|
||||||
@ -127,7 +151,7 @@ export default function GeographicMap({
|
|||||||
// Show loading state during SSR or until client-side rendering takes over
|
// Show loading state during SSR or until client-side rendering takes over
|
||||||
if (!isClient) {
|
if (!isClient) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
<div className="h-full w-full bg-muted flex items-center justify-center text-muted-foreground">
|
||||||
Loading map...
|
Loading map...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -136,9 +160,9 @@ export default function GeographicMap({
|
|||||||
return (
|
return (
|
||||||
<div style={{ height: `${height}px`, width: "100%" }} className="relative">
|
<div style={{ height: `${height}px`, width: "100%" }} className="relative">
|
||||||
{countryData.length > 0 ? (
|
{countryData.length > 0 ? (
|
||||||
<Map countryData={countryData} maxCount={maxCount} />
|
<CountryMapComponent countryData={countryData} maxCount={maxCount} />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full w-full bg-gray-100 flex items-center justify-center text-gray-500">
|
<div className="h-full w-full bg-muted flex items-center justify-center text-muted-foreground">
|
||||||
No geographic data available
|
No geographic data available
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MapContainer, TileLayer, CircleMarker, Tooltip } from "react-leaflet";
|
import { CircleMarker, MapContainer, TileLayer, Tooltip } from "react-leaflet";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { getLocalizedCountryName } from "../lib/localization";
|
import { getLocalizedCountryName } from "../lib/localization";
|
||||||
|
|
||||||
interface CountryData {
|
interface CountryData {
|
||||||
@ -15,7 +17,30 @@ interface MapProps {
|
|||||||
maxCount: number;
|
maxCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Map = ({ countryData, maxCount }: MapProps) => {
|
const CountryMap = ({ countryData, maxCount }: MapProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Don't render until mounted to avoid hydration mismatch
|
||||||
|
if (!mounted) {
|
||||||
|
return <div className="h-full w-full bg-muted animate-pulse rounded-lg" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
|
// Use different tile layers based on theme
|
||||||
|
const tileLayerUrl = isDark
|
||||||
|
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||||
|
: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
|
||||||
|
|
||||||
|
const tileLayerAttribution = isDark
|
||||||
|
? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
||||||
|
: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={[30, 0]}
|
center={[30, 0]}
|
||||||
@ -24,29 +49,28 @@ const Map = ({ countryData, maxCount }: MapProps) => {
|
|||||||
scrollWheelZoom={false}
|
scrollWheelZoom={false}
|
||||||
style={{ height: "100%", width: "100%", borderRadius: "0.5rem" }}
|
style={{ height: "100%", width: "100%", borderRadius: "0.5rem" }}
|
||||||
>
|
>
|
||||||
<TileLayer
|
<TileLayer attribution={tileLayerAttribution} url={tileLayerUrl} />
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
|
||||||
{countryData.map((country) => (
|
{countryData.map((country) => (
|
||||||
<CircleMarker
|
<CircleMarker
|
||||||
key={country.code}
|
key={country.code}
|
||||||
center={country.coordinates}
|
center={country.coordinates}
|
||||||
radius={5 + (country.count / maxCount) * 20}
|
radius={5 + (country.count / maxCount) * 20}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
fillColor: "#3B82F6",
|
fillColor: "hsl(var(--primary))",
|
||||||
color: "#1E40AF",
|
color: "hsl(var(--primary))",
|
||||||
weight: 1,
|
weight: 2,
|
||||||
opacity: 0.8,
|
opacity: 0.9,
|
||||||
fillOpacity: 0.6,
|
fillOpacity: 0.6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<div className="p-1">
|
<div className="p-2 bg-background border border-border rounded-md shadow-md">
|
||||||
<div className="font-medium">
|
<div className="font-medium text-foreground">
|
||||||
{getLocalizedCountryName(country.code)}
|
{getLocalizedCountryName(country.code)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm">Sessions: {country.count}</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Sessions: {country.count}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</CircleMarker>
|
</CircleMarker>
|
||||||
@ -55,4 +79,4 @@ const Map = ({ countryData, maxCount }: MapProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Map;
|
export default CountryMap;
|
||||||
|
|||||||
85
components/MessageViewer.tsx
Normal file
85
components/MessageViewer.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Message } from "../lib/types";
|
||||||
|
|
||||||
|
interface MessageViewerProps {
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to display parsed messages in a chat-like format
|
||||||
|
*/
|
||||||
|
export default function MessageViewer({ messages }: MessageViewerProps) {
|
||||||
|
if (!messages || messages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow">
|
||||||
|
<h3 className="font-bold text-lg mb-3">Conversation</h3>
|
||||||
|
<p className="text-gray-500 italic">No parsed messages available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow">
|
||||||
|
<h3 className="font-bold text-lg mb-3">
|
||||||
|
Conversation ({messages.length} messages)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`flex ${
|
||||||
|
message.role.toLowerCase() === "user"
|
||||||
|
? "justify-end"
|
||||||
|
: "justify-start"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
|
||||||
|
message.role.toLowerCase() === "user"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: message.role.toLowerCase() === "assistant"
|
||||||
|
? "bg-gray-200 text-gray-800"
|
||||||
|
: "bg-yellow-100 text-yellow-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs font-medium opacity-75 mr-2">
|
||||||
|
{message.role}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs opacity-75 ml-2">
|
||||||
|
{message.timestamp
|
||||||
|
? new Date(message.timestamp).toLocaleTimeString()
|
||||||
|
: "No timestamp"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm whitespace-pre-wrap">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-3 border-t text-sm text-gray-500">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>
|
||||||
|
First message:{" "}
|
||||||
|
{messages[0].timestamp
|
||||||
|
? new Date(messages[0].timestamp).toLocaleString()
|
||||||
|
: "No timestamp"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Last message: {(() => {
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
return lastMessage.timestamp
|
||||||
|
? new Date(lastMessage.timestamp).toLocaleString()
|
||||||
|
: "No timestamp";
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,88 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
interface MetricCardProps {
|
|
||||||
title: string;
|
|
||||||
value: string | number | null | undefined;
|
|
||||||
description?: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
trend?: {
|
|
||||||
value: number;
|
|
||||||
label?: string;
|
|
||||||
isPositive?: boolean;
|
|
||||||
};
|
|
||||||
variant?: "default" | "primary" | "success" | "warning" | "danger";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MetricCard({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
trend,
|
|
||||||
variant = "default",
|
|
||||||
}: MetricCardProps) {
|
|
||||||
// Determine background and text colors based on variant
|
|
||||||
const getVariantClasses = () => {
|
|
||||||
switch (variant) {
|
|
||||||
case "primary":
|
|
||||||
return "bg-blue-50 border-blue-200";
|
|
||||||
case "success":
|
|
||||||
return "bg-green-50 border-green-200";
|
|
||||||
case "warning":
|
|
||||||
return "bg-amber-50 border-amber-200";
|
|
||||||
case "danger":
|
|
||||||
return "bg-red-50 border-red-200";
|
|
||||||
default:
|
|
||||||
return "bg-white border-gray-200";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIconClasses = () => {
|
|
||||||
switch (variant) {
|
|
||||||
case "primary":
|
|
||||||
return "bg-blue-100 text-blue-600";
|
|
||||||
case "success":
|
|
||||||
return "bg-green-100 text-green-600";
|
|
||||||
case "warning":
|
|
||||||
return "bg-amber-100 text-amber-600";
|
|
||||||
case "danger":
|
|
||||||
return "bg-red-100 text-red-600";
|
|
||||||
default:
|
|
||||||
return "bg-gray-100 text-gray-600";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`rounded-xl border shadow-sm p-6 ${getVariantClasses()}`}>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-500">{title}</p>
|
|
||||||
<div className="mt-2 flex items-baseline">
|
|
||||||
<p className="text-2xl font-semibold">{value ?? "-"}</p>
|
|
||||||
{trend && (
|
|
||||||
<span
|
|
||||||
className={`ml-2 text-sm font-medium ${
|
|
||||||
trend.isPositive !== false ? "text-green-600" : "text-red-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{trend.isPositive !== false ? "↑" : "↓"}{" "}
|
|
||||||
{Math.abs(trend.value).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{description && (
|
|
||||||
<p className="mt-1 text-xs text-gray-500">{description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{icon && (
|
|
||||||
<div
|
|
||||||
className={`flex h-12 w-12 rounded-full ${getIconClasses()} items-center justify-center`}
|
|
||||||
>
|
|
||||||
<span className="text-xl">{icon}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,10 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
import {
|
||||||
import Chart from "chart.js/auto";
|
Bar,
|
||||||
import annotationPlugin from "chartjs-plugin-annotation";
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
Chart.register(annotationPlugin);
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
interface ResponseTimeDistributionProps {
|
interface ResponseTimeDistributionProps {
|
||||||
data: number[];
|
data: number[];
|
||||||
@ -12,18 +17,41 @@ interface ResponseTimeDistributionProps {
|
|||||||
targetResponseTime?: number;
|
targetResponseTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ value: number; payload: { label: string; count: number } }>;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{payload[0].value}
|
||||||
|
</span>{" "}
|
||||||
|
responses
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ResponseTimeDistribution({
|
export default function ResponseTimeDistribution({
|
||||||
data,
|
data,
|
||||||
average,
|
average,
|
||||||
targetResponseTime,
|
targetResponseTime,
|
||||||
}: ResponseTimeDistributionProps) {
|
}: ResponseTimeDistributionProps) {
|
||||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
if (!data || !data.length) {
|
||||||
|
return (
|
||||||
useEffect(() => {
|
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||||
if (!ref.current || !data || !data.length) return;
|
No response time data available
|
||||||
|
</div>
|
||||||
const ctx = ref.current.getContext("2d");
|
);
|
||||||
if (!ctx) return;
|
}
|
||||||
|
|
||||||
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
||||||
const maxTime = Math.ceil(Math.max(...data));
|
const maxTime = Math.ceil(Math.max(...data));
|
||||||
@ -35,91 +63,111 @@ export default function ResponseTimeDistribution({
|
|||||||
bins[binIndex]++;
|
bins[binIndex]++;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create labels for each bin
|
// Create chart data
|
||||||
const labels = bins.map((_, i) => {
|
const chartData = bins.map((count, i) => {
|
||||||
|
let label: string;
|
||||||
if (i === bins.length - 1 && bins.length < maxTime + 1) {
|
if (i === bins.length - 1 && bins.length < maxTime + 1) {
|
||||||
return `${i}+ seconds`;
|
label = `${i}+ sec`;
|
||||||
|
} else {
|
||||||
|
label = `${i}-${i + 1} sec`;
|
||||||
}
|
}
|
||||||
return `${i}-${i + 1} seconds`;
|
|
||||||
|
// Determine color based on response time
|
||||||
|
let color: string;
|
||||||
|
if (i <= 2)
|
||||||
|
color = "hsl(var(--chart-1))"; // Green for fast
|
||||||
|
else if (i <= 5)
|
||||||
|
color = "hsl(var(--chart-4))"; // Yellow for medium
|
||||||
|
else color = "hsl(var(--chart-3))"; // Red for slow
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: label,
|
||||||
|
value: count,
|
||||||
|
color,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const chart = new Chart(ctx, {
|
return (
|
||||||
type: "bar",
|
<div className="h-64">
|
||||||
data: {
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
labels,
|
<BarChart
|
||||||
datasets: [
|
data={chartData}
|
||||||
{
|
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||||
label: "Responses",
|
>
|
||||||
data: bins,
|
<CartesianGrid
|
||||||
backgroundColor: bins.map((_, i) => {
|
strokeDasharray="3 3"
|
||||||
// Green for fast, yellow for medium, red for slow
|
stroke="hsl(var(--border))"
|
||||||
if (i <= 2) return "rgba(34, 197, 94, 0.7)"; // Green
|
strokeOpacity={0.3}
|
||||||
if (i <= 5) return "rgba(250, 204, 21, 0.7)"; // Yellow
|
/>
|
||||||
return "rgba(239, 68, 68, 0.7)"; // Red
|
<XAxis
|
||||||
}),
|
dataKey="name"
|
||||||
borderWidth: 1,
|
stroke="hsl(var(--muted-foreground))"
|
||||||
},
|
fontSize={12}
|
||||||
],
|
tickLine={false}
|
||||||
},
|
axisLine={false}
|
||||||
options: {
|
/>
|
||||||
responsive: true,
|
<YAxis
|
||||||
plugins: {
|
stroke="hsl(var(--muted-foreground))"
|
||||||
legend: { display: false },
|
fontSize={12}
|
||||||
annotation: {
|
tickLine={false}
|
||||||
annotations: {
|
axisLine={false}
|
||||||
averageLine: {
|
label={{
|
||||||
type: "line",
|
value: "Number of Responses",
|
||||||
yMin: 0,
|
angle: -90,
|
||||||
yMax: Math.max(...bins),
|
position: "insideLeft",
|
||||||
xMin: average,
|
style: { textAnchor: "middle" },
|
||||||
xMax: average,
|
}}
|
||||||
borderColor: "rgba(75, 192, 192, 1)",
|
/>
|
||||||
borderWidth: 2,
|
<Tooltip content={<CustomTooltip />} />
|
||||||
label: {
|
|
||||||
display: true,
|
|
||||||
content: "Avg: " + average.toFixed(1) + "s",
|
|
||||||
position: "start",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
targetLine: targetResponseTime
|
|
||||||
? {
|
|
||||||
type: "line",
|
|
||||||
yMin: 0,
|
|
||||||
yMax: Math.max(...bins),
|
|
||||||
xMin: targetResponseTime,
|
|
||||||
xMax: targetResponseTime,
|
|
||||||
borderColor: "rgba(75, 192, 192, 0.7)",
|
|
||||||
borderWidth: 2,
|
|
||||||
label: {
|
|
||||||
display: true,
|
|
||||||
content: "Target",
|
|
||||||
position: "end",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "Number of Responses",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "Response Time",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => chart.destroy();
|
<Bar
|
||||||
}, [data, average, targetResponseTime]);
|
dataKey="value"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
fill="hsl(var(--chart-1))"
|
||||||
|
maxBarSize={60}
|
||||||
|
>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Bar key={`cell-${entry.name}-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
|
||||||
return <canvas ref={ref} height={180} />;
|
{/* Average line */}
|
||||||
|
<ReferenceLine
|
||||||
|
x={Math.floor(average)}
|
||||||
|
stroke="hsl(var(--primary))"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
label={{
|
||||||
|
value: `Avg: ${average.toFixed(1)}s`,
|
||||||
|
position: "top" as const,
|
||||||
|
style: {
|
||||||
|
fill: "hsl(var(--primary))",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Target line (if provided) */}
|
||||||
|
{targetResponseTime && (
|
||||||
|
<ReferenceLine
|
||||||
|
x={Math.floor(targetResponseTime)}
|
||||||
|
stroke="hsl(var(--chart-2))"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
label={{
|
||||||
|
value: `Target: ${targetResponseTime}s`,
|
||||||
|
position: "top" as const,
|
||||||
|
style: {
|
||||||
|
fill: "hsl(var(--chart-2))",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChatSession } from "../lib/types";
|
import { ExternalLink } from "lucide-react";
|
||||||
import LanguageDisplay from "./LanguageDisplay";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { formatCategory } from "@/lib/format-enums";
|
||||||
|
import type { ChatSession } from "../lib/types";
|
||||||
import CountryDisplay from "./CountryDisplay";
|
import CountryDisplay from "./CountryDisplay";
|
||||||
|
import LanguageDisplay from "./LanguageDisplay";
|
||||||
|
|
||||||
interface SessionDetailsProps {
|
interface SessionDetailsProps {
|
||||||
session: ChatSession;
|
session: ChatSession;
|
||||||
@ -12,155 +17,183 @@ interface SessionDetailsProps {
|
|||||||
* Component to display session details with formatted country and language names
|
* Component to display session details with formatted country and language names
|
||||||
*/
|
*/
|
||||||
export default function SessionDetails({ session }: SessionDetailsProps) {
|
export default function SessionDetails({ session }: SessionDetailsProps) {
|
||||||
return (
|
// Using centralized formatCategory utility
|
||||||
<div className="bg-white p-4 rounded-lg shadow">
|
|
||||||
<h3 className="font-bold text-lg mb-3">Session Details</h3>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
return (
|
||||||
<div className="flex justify-between border-b pb-2">
|
<Card>
|
||||||
<span className="text-gray-600">Session ID:</span>
|
<CardHeader>
|
||||||
<span className="font-medium">{session.sessionId || session.id}</span>
|
<CardTitle>Session Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Session ID</p>
|
||||||
|
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">
|
||||||
|
{session.id.slice(0, 8)}...
|
||||||
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between border-b pb-2">
|
<div>
|
||||||
<span className="text-gray-600">Start Time:</span>
|
<p className="text-sm text-muted-foreground">Start Time</p>
|
||||||
<span className="font-medium">
|
<p className="font-medium">
|
||||||
{new Date(session.startTime).toLocaleString()}
|
{new Date(session.startTime).toLocaleString()}
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{session.endTime && (
|
{session.endTime && (
|
||||||
<div className="flex justify-between border-b pb-2">
|
<div>
|
||||||
<span className="text-gray-600">End Time:</span>
|
<p className="text-sm text-muted-foreground">End Time</p>
|
||||||
<span className="font-medium">
|
<p className="font-medium">
|
||||||
{new Date(session.endTime).toLocaleString()}
|
{new Date(session.endTime).toLocaleString()}
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.category && (
|
{session.category && (
|
||||||
<div className="flex justify-between border-b pb-2">
|
<div>
|
||||||
<span className="text-gray-600">Category:</span>
|
<p className="text-sm text-muted-foreground">Category</p>
|
||||||
<span className="font-medium">{session.category}</span>
|
<Badge variant="secondary">
|
||||||
|
{formatCategory(session.category)}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.language && (
|
{session.language && (
|
||||||
<div className="flex justify-between border-b pb-2">
|
<div>
|
||||||
<span className="text-gray-600">Language:</span>
|
<p className="text-sm text-muted-foreground">Language</p>
|
||||||
<span className="font-medium">
|
<div className="flex items-center gap-2">
|
||||||
<LanguageDisplay languageCode={session.language} />
|
<LanguageDisplay languageCode={session.language} />
|
||||||
<span className="text-gray-400 text-xs ml-1">
|
<Badge variant="outline" className="text-xs">
|
||||||
({session.language.toUpperCase()})
|
{session.language.toUpperCase()}
|
||||||
</span>
|
</Badge>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.country && (
|
{session.country && (
|
||||||
<div className="flex justify-between border-b pb-2">
|
<div>
|
||||||
<span className="text-gray-600">Country:</span>
|
<p className="text-sm text-muted-foreground">Country</p>
|
||||||
<span className="font-medium">
|
<div className="flex items-center gap-2">
|
||||||
<CountryDisplay countryCode={session.country} />
|
<CountryDisplay countryCode={session.country} />
|
||||||
<span className="text-gray-400 text-xs ml-1">
|
<Badge variant="outline" className="text-xs">
|
||||||
({session.country})
|
{session.country}
|
||||||
</span>
|
</Badge>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
{session.sentiment !== null && session.sentiment !== undefined && (
|
{session.sentiment !== null && session.sentiment !== undefined && (
|
||||||
<div className="flex justify-between border-b pb-2">
|
<div>
|
||||||
<span className="text-gray-600">Sentiment:</span>
|
<p className="text-sm text-muted-foreground">Sentiment</p>
|
||||||
<span
|
<Badge
|
||||||
className={`font-medium ${
|
variant={
|
||||||
session.sentiment > 0.3
|
session.sentiment === "positive"
|
||||||
? "text-green-500"
|
? "default"
|
||||||
: session.sentiment < -0.3
|
: session.sentiment === "negative"
|
||||||
? "text-red-500"
|
? "destructive"
|
||||||
: "text-orange-500"
|
: "secondary"
|
||||||
}`}
|
}
|
||||||
>
|
>
|
||||||
{session.sentiment > 0.3
|
{session.sentiment.charAt(0).toUpperCase() +
|
||||||
? "Positive"
|
session.sentiment.slice(1)}
|
||||||
: session.sentiment < -0.3
|
</Badge>
|
||||||
? "Negative"
|
|
||||||
: "Neutral"}{" "}
|
|
||||||
({session.sentiment.toFixed(2)})
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between border-b pb-2">
|
<div>
|
||||||
<span className="text-gray-600">Messages Sent:</span>
|
<p className="text-sm text-muted-foreground">Messages Sent</p>
|
||||||
<span className="font-medium">{session.messagesSent || 0}</span>
|
<p className="font-medium">{session.messagesSent || 0}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{typeof session.tokens === "number" && (
|
|
||||||
<div className="flex justify-between border-b pb-2">
|
|
||||||
<span className="text-gray-600">Tokens:</span>
|
|
||||||
<span className="font-medium">{session.tokens}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{typeof session.tokensEur === "number" && (
|
|
||||||
<div className="flex justify-between border-b pb-2">
|
|
||||||
<span className="text-gray-600">Cost:</span>
|
|
||||||
<span className="font-medium">€{session.tokensEur.toFixed(4)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{session.avgResponseTime !== null &&
|
{session.avgResponseTime !== null &&
|
||||||
session.avgResponseTime !== undefined && (
|
session.avgResponseTime !== undefined && (
|
||||||
<div className="flex justify-between border-b pb-2">
|
<div>
|
||||||
<span className="text-gray-600">Avg Response Time:</span>
|
<p className="text-sm text-muted-foreground">
|
||||||
<span className="font-medium">
|
Avg Response Time
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
{session.avgResponseTime.toFixed(2)}s
|
{session.avgResponseTime.toFixed(2)}s
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.escalated !== null && session.escalated !== undefined && (
|
{session.escalated !== null && session.escalated !== undefined && (
|
||||||
<div className="flex justify-between border-b pb-2">
|
<div>
|
||||||
<span className="text-gray-600">Escalated:</span>
|
<p className="text-sm text-muted-foreground">Escalated</p>
|
||||||
<span
|
<Badge variant={session.escalated ? "destructive" : "default"}>
|
||||||
className={`font-medium ${session.escalated ? "text-red-500" : "text-green-500"}`}
|
|
||||||
>
|
|
||||||
{session.escalated ? "Yes" : "No"}
|
{session.escalated ? "Yes" : "No"}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.forwardedHr !== null && session.forwardedHr !== undefined && (
|
{session.forwardedHr !== null &&
|
||||||
<div className="flex justify-between border-b pb-2">
|
session.forwardedHr !== undefined && (
|
||||||
<span className="text-gray-600">Forwarded to HR:</span>
|
<div>
|
||||||
<span
|
<p className="text-sm text-muted-foreground">
|
||||||
className={`font-medium ${session.forwardedHr ? "text-amber-500" : "text-green-500"}`}
|
Forwarded to HR
|
||||||
|
</p>
|
||||||
|
<Badge
|
||||||
|
variant={session.forwardedHr ? "secondary" : "default"}
|
||||||
>
|
>
|
||||||
{session.forwardedHr ? "Yes" : "No"}
|
{session.forwardedHr ? "Yes" : "No"}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Transcript rendering is now handled by the parent page (app/dashboard/sessions/[id]/page.tsx) */}
|
{session.ipAddress && (
|
||||||
{/* Fallback to link only if we only have the URL but no content - this might also be redundant if parent handles all transcript display */}
|
<div>
|
||||||
{(!session.transcriptContent ||
|
<p className="text-sm text-muted-foreground">IP Address</p>
|
||||||
session.transcriptContent.length === 0) &&
|
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">
|
||||||
session.fullTranscriptUrl &&
|
{session.ipAddress}
|
||||||
process.env.NODE_ENV !== "production" && (
|
</code>
|
||||||
<div className="flex justify-between pt-2">
|
</div>
|
||||||
<span className="text-gray-600">Transcript:</span>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(session.summary || session.initialMsg) && <Separator />}
|
||||||
|
|
||||||
|
{session.summary && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">AI Summary</p>
|
||||||
|
<div className="bg-muted p-3 rounded-md text-sm">
|
||||||
|
{session.summary}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!session.summary && session.initialMsg && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Initial Message
|
||||||
|
</p>
|
||||||
|
<div className="bg-muted p-3 rounded-md text-sm italic">
|
||||||
|
"{session.initialMsg}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.fullTranscriptUrl && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
<a
|
<a
|
||||||
href={session.fullTranscriptUrl}
|
href={session.fullTranscriptUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-500 hover:text-blue-700 underline"
|
className="inline-flex items-center gap-2 text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||||
|
aria-label="Open full transcript in new tab"
|
||||||
>
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||||
View Full Transcript
|
View Full Transcript
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react"; // No hooks needed since state is now managed by parent
|
|
||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
|
import type React from "react"; // No hooks needed since state is now managed by parent
|
||||||
|
import { useId } from "react";
|
||||||
|
import { SimpleThemeToggle } from "@/components/ui/theme-toggle";
|
||||||
|
|
||||||
// Icons for the sidebar
|
// Icons for the sidebar
|
||||||
const DashboardIcon = () => (
|
const DashboardIcon = () => (
|
||||||
@ -15,6 +17,7 @@ const DashboardIcon = () => (
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
|
<title>Dashboard</title>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -50,6 +53,7 @@ const CompanyIcon = () => (
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
|
<title>Company</title>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -67,6 +71,7 @@ const UsersIcon = () => (
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
|
<title>Users</title>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -84,6 +89,7 @@ const SessionsIcon = () => (
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
|
<title>Sessions</title>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -101,6 +107,7 @@ const LogoutIcon = () => (
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
|
<title>Logout</title>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -118,6 +125,7 @@ const MinimalToggleIcon = ({ isExpanded }: { isExpanded: boolean }) => (
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
|
<title>{isExpanded ? "Collapse sidebar" : "Expand sidebar"}</title>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
) : (
|
) : (
|
||||||
@ -158,8 +166,8 @@ const NavItem: React.FC<NavItemProps> = ({
|
|||||||
href={href}
|
href={href}
|
||||||
className={`relative flex items-center p-3 my-1 rounded-lg transition-all group ${
|
className={`relative flex items-center p-3 my-1 rounded-lg transition-all group ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-sky-100 text-sky-800 font-medium"
|
? "bg-primary/10 text-primary font-medium border border-primary/20"
|
||||||
: "hover:bg-gray-100 text-gray-700 hover:text-gray-900"
|
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onNavigate) {
|
if (onNavigate) {
|
||||||
@ -167,7 +175,7 @@ const NavItem: React.FC<NavItemProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className={`flex-shrink-0 ${isExpanded ? "mr-3" : "mx-auto"}`}>
|
<span className={`shrink-0 ${isExpanded ? "mr-3" : "mx-auto"}`}>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
@ -175,7 +183,7 @@ const NavItem: React.FC<NavItemProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||||
text-white bg-gray-800 z-50
|
text-popover-foreground bg-popover border border-border z-50
|
||||||
invisible opacity-0 -translate-x-3 transition-all
|
invisible opacity-0 -translate-x-3 transition-all
|
||||||
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||||
>
|
>
|
||||||
@ -191,6 +199,7 @@ export default function Sidebar({
|
|||||||
isMobile = false,
|
isMobile = false,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
|
const sidebarId = useId();
|
||||||
const pathname = usePathname() || "";
|
const pathname = usePathname() || "";
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@ -202,13 +211,22 @@ export default function Sidebar({
|
|||||||
{/* Backdrop overlay when sidebar is expanded on mobile */}
|
{/* Backdrop overlay when sidebar is expanded on mobile */}
|
||||||
{isExpanded && isMobile && (
|
{isExpanded && isMobile && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-gray-900 bg-opacity-50 z-10 transition-opacity duration-300"
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-10 transition-all duration-300"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onToggle();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Close sidebar"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`fixed md:relative h-screen bg-white shadow-md transition-all duration-300
|
id={sidebarId}
|
||||||
|
className={`fixed md:relative h-screen bg-card border-r border-border shadow-lg transition-all duration-300
|
||||||
${
|
${
|
||||||
isExpanded ? (isMobile ? "w-full sm:w-80" : "w-56") : "w-16"
|
isExpanded ? (isMobile ? "w-full sm:w-80" : "w-56") : "w-16"
|
||||||
} flex flex-col overflow-visible z-20`}
|
} flex flex-col overflow-visible z-20`}
|
||||||
@ -218,12 +236,15 @@ export default function Sidebar({
|
|||||||
{!isExpanded && (
|
{!isExpanded && (
|
||||||
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 z-30">
|
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 z-30">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault(); // Prevent any navigation
|
e.preventDefault(); // Prevent any navigation
|
||||||
onToggle();
|
onToggle();
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors group"
|
className="p-1.5 rounded-md hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors group"
|
||||||
title="Expand sidebar"
|
aria-label="Expand sidebar"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-controls={sidebarId}
|
||||||
>
|
>
|
||||||
<MinimalToggleIcon isExpanded={isExpanded} />
|
<MinimalToggleIcon isExpanded={isExpanded} />
|
||||||
</button>
|
</button>
|
||||||
@ -248,7 +269,7 @@ export default function Sidebar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<span className="text-lg font-bold text-sky-700 mt-1 transition-opacity duration-300">
|
<span className="text-lg font-bold text-primary mt-1 transition-opacity duration-300">
|
||||||
LiveDash
|
LiveDash
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -257,18 +278,22 @@ export default function Sidebar({
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="absolute top-3 right-3 z-30">
|
<div className="absolute top-3 right-3 z-30">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault(); // Prevent any navigation
|
e.preventDefault(); // Prevent any navigation
|
||||||
onToggle();
|
onToggle();
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors group"
|
className="p-1.5 rounded-md hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors group"
|
||||||
title="Collapse sidebar"
|
aria-label="Collapse sidebar"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-controls="main-sidebar"
|
||||||
>
|
>
|
||||||
<MinimalToggleIcon isExpanded={isExpanded} />
|
<MinimalToggleIcon isExpanded={isExpanded} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<nav
|
<nav
|
||||||
|
aria-label="Main navigation"
|
||||||
className={`flex-1 py-4 px-2 overflow-y-auto overflow-x-visible ${isExpanded ? "pt-12" : "pt-4"}`}
|
className={`flex-1 py-4 px-2 overflow-y-auto overflow-x-visible ${isExpanded ? "pt-12" : "pt-4"}`}
|
||||||
>
|
>
|
||||||
<NavItem
|
<NavItem
|
||||||
@ -290,6 +315,7 @@ export default function Sidebar({
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
|
<title>Analytics Chart</title>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -327,14 +353,28 @@ export default function Sidebar({
|
|||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-4 border-t mt-auto">
|
<div className="p-4 border-t mt-auto space-y-2">
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center ${isExpanded ? "justify-between" : "justify-center"}`}
|
||||||
|
>
|
||||||
|
{isExpanded && (
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Theme
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<SimpleThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className={`relative flex items-center p-3 w-full rounded-lg text-gray-700 hover:bg-gray-100 hover:text-gray-900 transition-all group ${
|
className={`relative flex items-center p-3 w-full rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-all group ${
|
||||||
isExpanded ? "" : "justify-center"
|
isExpanded ? "" : "justify-center"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`flex-shrink-0 ${isExpanded ? "mr-3" : ""}`}>
|
<span className={`shrink-0 ${isExpanded ? "mr-3" : ""}`}>
|
||||||
<LogoutIcon />
|
<LogoutIcon />
|
||||||
</span>
|
</span>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
@ -342,7 +382,7 @@ export default function Sidebar({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||||
text-white bg-gray-800 z-50
|
text-popover-foreground bg-popover border border-border z-50
|
||||||
invisible opacity-0 -translate-x-3 transition-all
|
invisible opacity-0 -translate-x-3 transition-all
|
||||||
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||||
>
|
>
|
||||||
|
|||||||
87
components/TopQuestionsChart.tsx
Normal file
87
components/TopQuestionsChart.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import type { TopQuestion } from "../lib/types";
|
||||||
|
|
||||||
|
interface TopQuestionsChartProps {
|
||||||
|
data: TopQuestion[];
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopQuestionsChart({
|
||||||
|
data,
|
||||||
|
title = "Top 5 Asked Questions",
|
||||||
|
}: TopQuestionsChartProps) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No questions data available
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the maximum count to calculate relative bar widths
|
||||||
|
const maxCount = Math.max(...data.map((q) => q.count));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.map((question) => {
|
||||||
|
const percentage =
|
||||||
|
maxCount > 0 ? (question.count / maxCount) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={question.question} className="relative pl-8">
|
||||||
|
{/* Question text */}
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<p className="text-sm font-medium leading-tight pr-4 flex-1 text-foreground">
|
||||||
|
{question.question}
|
||||||
|
</p>
|
||||||
|
<Badge variant="secondary" className="whitespace-nowrap">
|
||||||
|
{question.count}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full bg-muted rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-2 rounded-full transition-all duration-300 ease-in-out"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rank indicator */}
|
||||||
|
<div className="absolute -left-1 top-0 w-6 h-6 bg-primary text-primary-foreground text-xs font-bold rounded-full flex items-center justify-center">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-6" />
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="flex justify-between text-sm text-muted-foreground">
|
||||||
|
<span>Total questions analyzed</span>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{data.reduce((sum, q) => sum + q.count, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import rehypeRaw from "rehype-raw";
|
import rehypeRaw from "rehype-raw"; // Import rehype-raw
|
||||||
|
|
||||||
interface TranscriptViewerProps {
|
interface TranscriptViewerProps {
|
||||||
transcriptContent: string;
|
transcriptContent: string;
|
||||||
@ -23,25 +23,17 @@ function formatTranscript(content: string): React.ReactNode[] {
|
|||||||
const elements: React.ReactNode[] = [];
|
const elements: React.ReactNode[] = [];
|
||||||
let currentSpeaker: string | null = null;
|
let currentSpeaker: string | null = null;
|
||||||
let currentMessages: string[] = [];
|
let currentMessages: string[] = [];
|
||||||
let currentTimestamp: string | null = null;
|
|
||||||
|
|
||||||
// Process each line
|
// Process each line
|
||||||
lines.forEach((line) => {
|
lines.forEach((line) => {
|
||||||
line = line.trim();
|
const trimmedLine = line.trim();
|
||||||
if (!line) {
|
if (!trimmedLine) {
|
||||||
// Empty line, ignore
|
// Empty line, ignore
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a new speaker line with or without datetime
|
// Check if this is a new speaker line
|
||||||
// Format 1: [29.05.2025 21:26:44] User: message
|
if (line.startsWith("User:") || line.startsWith("Assistant:")) {
|
||||||
// Format 2: User: message
|
|
||||||
const datetimeMatch = line.match(
|
|
||||||
/^\[([^\]]+)\]\s*(User|Assistant):\s*(.*)$/
|
|
||||||
);
|
|
||||||
const simpleMatch = line.match(/^(User|Assistant):\s*(.*)$/);
|
|
||||||
|
|
||||||
if (datetimeMatch || simpleMatch) {
|
|
||||||
// If we have accumulated messages for a previous speaker, add them
|
// If we have accumulated messages for a previous speaker, add them
|
||||||
if (currentSpeaker && currentMessages.length > 0) {
|
if (currentSpeaker && currentMessages.length > 0) {
|
||||||
elements.push(
|
elements.push(
|
||||||
@ -56,15 +48,10 @@ function formatTranscript(content: string): React.ReactNode[] {
|
|||||||
: "bg-gray-100 text-gray-800"
|
: "bg-gray-100 text-gray-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{currentTimestamp && (
|
|
||||||
<div className="text-xs opacity-60 mb-1">
|
|
||||||
{currentTimestamp}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{currentMessages.map((msg, i) => (
|
{currentMessages.map((msg, i) => (
|
||||||
// Use ReactMarkdown to render each message part
|
// Use ReactMarkdown to render each message part
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
key={i}
|
key={`msg-${msg.substring(0, 20).replace(/\s/g, "-")}-${i}`}
|
||||||
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
||||||
components={{
|
components={{
|
||||||
p: "span",
|
p: "span",
|
||||||
@ -86,26 +73,18 @@ function formatTranscript(content: string): React.ReactNode[] {
|
|||||||
currentMessages = [];
|
currentMessages = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (datetimeMatch) {
|
// Set the new current speaker
|
||||||
// Format with datetime: [29.05.2025 21:26:44] User: message
|
currentSpeaker = trimmedLine.startsWith("User:") ? "User" : "Assistant";
|
||||||
currentTimestamp = datetimeMatch[1];
|
// Add the content after "User:" or "Assistant:"
|
||||||
currentSpeaker = datetimeMatch[2];
|
const messageContent = trimmedLine
|
||||||
const messageContent = datetimeMatch[3].trim();
|
.substring(trimmedLine.indexOf(":") + 1)
|
||||||
|
.trim();
|
||||||
if (messageContent) {
|
if (messageContent) {
|
||||||
currentMessages.push(messageContent);
|
currentMessages.push(messageContent);
|
||||||
}
|
}
|
||||||
} else if (simpleMatch) {
|
|
||||||
// Format without datetime: User: message
|
|
||||||
currentTimestamp = null;
|
|
||||||
currentSpeaker = simpleMatch[1];
|
|
||||||
const messageContent = simpleMatch[2].trim();
|
|
||||||
if (messageContent) {
|
|
||||||
currentMessages.push(messageContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (currentSpeaker) {
|
} else if (currentSpeaker) {
|
||||||
// This is a continuation of the current speaker's message
|
// This is a continuation of the current speaker's message
|
||||||
currentMessages.push(line);
|
currentMessages.push(trimmedLine);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -123,13 +102,10 @@ function formatTranscript(content: string): React.ReactNode[] {
|
|||||||
: "bg-gray-100 text-gray-800"
|
: "bg-gray-100 text-gray-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{currentTimestamp && (
|
|
||||||
<div className="text-xs opacity-60 mb-1">{currentTimestamp}</div>
|
|
||||||
)}
|
|
||||||
{currentMessages.map((msg, i) => (
|
{currentMessages.map((msg, i) => (
|
||||||
// Use ReactMarkdown to render each message part
|
// Use ReactMarkdown to render each message part
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
key={i}
|
key={`msg-final-${msg.substring(0, 20).replace(/\s/g, "-")}-${i}`}
|
||||||
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
||||||
components={{
|
components={{
|
||||||
p: "span",
|
p: "span",
|
||||||
@ -164,9 +140,6 @@ export default function TranscriptViewer({
|
|||||||
|
|
||||||
const formattedElements = formatTranscript(transcriptContent);
|
const formattedElements = formatTranscript(transcriptContent);
|
||||||
|
|
||||||
// Hide "View Full Raw" button in production environment
|
|
||||||
const isProduction = process.env.NODE_ENV === "production";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow-lg rounded-lg p-4 md:p-6 mt-6">
|
<div className="bg-white shadow-lg rounded-lg p-4 md:p-6 mt-6">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
@ -174,7 +147,7 @@ export default function TranscriptViewer({
|
|||||||
Session Transcript
|
Session Transcript
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{transcriptUrl && !isProduction && (
|
{transcriptUrl && (
|
||||||
<a
|
<a
|
||||||
href={transcriptUrl}
|
href={transcriptUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -186,6 +159,7 @@ export default function TranscriptViewer({
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowRaw(!showRaw)}
|
onClick={() => setShowRaw(!showRaw)}
|
||||||
className="text-sm text-sky-600 hover:text-sky-800 hover:underline"
|
className="text-sm text-sky-600 hover:text-sky-800 hover:underline"
|
||||||
title={
|
title={
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export default function WelcomeBanner({ companyName }: WelcomeBannerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 text-white p-6 rounded-xl shadow-lg mb-8">
|
<div className="bg-linear-to-r from-blue-600 to-indigo-700 text-white p-6 rounded-xl shadow-lg mb-8">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">
|
<h1 className="text-3xl font-bold">
|
||||||
@ -48,7 +48,7 @@ export default function WelcomeBanner({ companyName }: WelcomeBannerProps) {
|
|||||||
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-lg">
|
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-lg">
|
||||||
<div className="text-sm opacity-75">Current Status</div>
|
<div className="text-sm opacity-75">Current Status</div>
|
||||||
<div className="text-xl font-semibold flex items-center">
|
<div className="text-xl font-semibold flex items-center">
|
||||||
<span className="inline-block w-2 h-2 bg-green-400 rounded-full mr-2"></span>
|
<span className="inline-block w-2 h-2 bg-green-400 rounded-full mr-2" />
|
||||||
All Systems Operational
|
All Systems Operational
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useEffect, useState } from "react";
|
import cloud, { type Word } from "d3-cloud";
|
||||||
import { select } from "d3-selection";
|
import { select } from "d3-selection";
|
||||||
import cloud, { Word } from "d3-cloud";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
interface WordCloudProps {
|
interface WordCloudProps {
|
||||||
words: {
|
words: {
|
||||||
|
|||||||
120
components/charts/bar-chart.tsx
Normal file
120
components/charts/bar-chart.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface BarChartData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
[key: string]: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarChartProps {
|
||||||
|
data: BarChartData[];
|
||||||
|
title?: string;
|
||||||
|
dataKey?: string;
|
||||||
|
colors?: string[];
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ value: number; name?: string }>;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{payload[0].value}
|
||||||
|
</span>{" "}
|
||||||
|
sessions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ModernBarChart({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
dataKey = "value",
|
||||||
|
colors = [
|
||||||
|
"hsl(var(--chart-1))",
|
||||||
|
"hsl(var(--chart-2))",
|
||||||
|
"hsl(var(--chart-3))",
|
||||||
|
"hsl(var(--chart-4))",
|
||||||
|
"hsl(var(--chart-5))",
|
||||||
|
],
|
||||||
|
height = 300,
|
||||||
|
className,
|
||||||
|
}: BarChartProps) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
{title && (
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<BarChart
|
||||||
|
data={data}
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
strokeOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={80}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar
|
||||||
|
dataKey={dataKey}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
className="transition-all duration-200"
|
||||||
|
>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${entry.name}-${index}`}
|
||||||
|
fill={colors[index % colors.length]}
|
||||||
|
className="hover:opacity-80"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
components/charts/donut-chart.tsx
Normal file
165
components/charts/donut-chart.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
} from "recharts";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface DonutChartProps {
|
||||||
|
data: Array<{ name: string; value: number; color?: string }>;
|
||||||
|
title?: string;
|
||||||
|
centerText?: {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
};
|
||||||
|
colors?: string[];
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
payload: { total: number };
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0];
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||||
|
<p className="text-sm font-medium">{data.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{data.value}</span>{" "}
|
||||||
|
sessions ({((data.value / data.payload.total) * 100).toFixed(1)}%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LegendProps {
|
||||||
|
payload?: Array<{
|
||||||
|
value: string;
|
||||||
|
color: string;
|
||||||
|
type?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomLegend = ({ payload }: LegendProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap justify-center gap-4 mt-4">
|
||||||
|
{payload?.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={`legend-${entry.value}-${index}`}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">{entry.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CenterLabelProps {
|
||||||
|
centerText?: {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
};
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CenterLabel = ({ centerText }: CenterLabelProps) => {
|
||||||
|
if (!centerText) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold">{centerText.value}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{centerText.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ModernDonutChart({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
centerText,
|
||||||
|
colors = [
|
||||||
|
"hsl(var(--chart-1))",
|
||||||
|
"hsl(var(--chart-2))",
|
||||||
|
"hsl(var(--chart-3))",
|
||||||
|
"hsl(var(--chart-4))",
|
||||||
|
"hsl(var(--chart-5))",
|
||||||
|
],
|
||||||
|
height = 300,
|
||||||
|
className,
|
||||||
|
}: DonutChartProps) {
|
||||||
|
const total = data.reduce((sum, item) => sum + item.value, 0);
|
||||||
|
const dataWithTotal = data.map((item) => ({ ...item, total }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
{title && (
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${title || "Chart"} - ${data.length} segments`}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={dataWithTotal}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={100}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
className="transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dataWithTotal.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${entry.name}-${index}`}
|
||||||
|
fill={entry.color || colors[index % colors.length]}
|
||||||
|
className="hover:opacity-80 cursor-pointer focus:opacity-80"
|
||||||
|
stroke="hsl(var(--background))"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend content={<CustomLegend />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<CenterLabel centerText={centerText} total={total} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
components/charts/line-chart.tsx
Normal file
134
components/charts/line-chart.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId } from "react";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface LineChartData {
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
[key: string]: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineChartProps {
|
||||||
|
data: LineChartData[];
|
||||||
|
title?: string;
|
||||||
|
dataKey?: string;
|
||||||
|
color?: string;
|
||||||
|
gradient?: boolean;
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ value: number; name?: string }>;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{payload[0].value}
|
||||||
|
</span>{" "}
|
||||||
|
sessions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ModernLineChart({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
dataKey = "value",
|
||||||
|
color = "hsl(var(--primary))",
|
||||||
|
gradient = true,
|
||||||
|
height = 300,
|
||||||
|
className,
|
||||||
|
}: LineChartProps) {
|
||||||
|
const gradientId = useId();
|
||||||
|
const ChartComponent = gradient ? AreaChart : LineChart;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
{title && (
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<ChartComponent
|
||||||
|
data={data}
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{gradient && (
|
||||||
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
)}
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
strokeOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
|
||||||
|
{gradient ? (
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey={dataKey}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
dot={{ fill: color, strokeWidth: 2, r: 4 }}
|
||||||
|
activeDot={{ r: 6, stroke: color, strokeWidth: 2 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey={dataKey}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: color, strokeWidth: 2, r: 4 }}
|
||||||
|
activeDot={{ r: 6, stroke: color, strokeWidth: 2 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartComponent>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
components/magicui/animated-beam.tsx
Normal file
185
components/magicui/animated-beam.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { type RefObject, useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface AnimatedBeamProps {
|
||||||
|
className?: string;
|
||||||
|
containerRef: RefObject<HTMLElement | null>; // Container ref
|
||||||
|
fromRef: RefObject<HTMLElement | null>;
|
||||||
|
toRef: RefObject<HTMLElement | null>;
|
||||||
|
curvature?: number;
|
||||||
|
reverse?: boolean;
|
||||||
|
pathColor?: string;
|
||||||
|
pathWidth?: number;
|
||||||
|
pathOpacity?: number;
|
||||||
|
gradientStartColor?: string;
|
||||||
|
gradientStopColor?: string;
|
||||||
|
delay?: number;
|
||||||
|
duration?: number;
|
||||||
|
startXOffset?: number;
|
||||||
|
startYOffset?: number;
|
||||||
|
endXOffset?: number;
|
||||||
|
endYOffset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnimatedBeam: React.FC<AnimatedBeamProps> = ({
|
||||||
|
className,
|
||||||
|
containerRef,
|
||||||
|
fromRef,
|
||||||
|
toRef,
|
||||||
|
curvature = 0,
|
||||||
|
reverse = false, // Include the reverse prop
|
||||||
|
duration = Math.random() * 3 + 4,
|
||||||
|
delay = 0,
|
||||||
|
pathColor = "gray",
|
||||||
|
pathWidth = 2,
|
||||||
|
pathOpacity = 0.2,
|
||||||
|
gradientStartColor = "#ffaa40",
|
||||||
|
gradientStopColor = "#9c40ff",
|
||||||
|
startXOffset = 0,
|
||||||
|
startYOffset = 0,
|
||||||
|
endXOffset = 0,
|
||||||
|
endYOffset = 0,
|
||||||
|
}) => {
|
||||||
|
const id = useId();
|
||||||
|
const [pathD, setPathD] = useState("");
|
||||||
|
const [svgDimensions, setSvgDimensions] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
// Calculate the gradient coordinates based on the reverse prop
|
||||||
|
const gradientCoordinates = reverse
|
||||||
|
? {
|
||||||
|
x1: ["90%", "-10%"],
|
||||||
|
x2: ["100%", "0%"],
|
||||||
|
y1: ["0%", "0%"],
|
||||||
|
y2: ["0%", "0%"],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
x1: ["10%", "110%"],
|
||||||
|
x2: ["0%", "100%"],
|
||||||
|
y1: ["0%", "0%"],
|
||||||
|
y2: ["0%", "0%"],
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updatePath = () => {
|
||||||
|
if (containerRef.current && fromRef.current && toRef.current) {
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
const rectA = fromRef.current.getBoundingClientRect();
|
||||||
|
const rectB = toRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
const svgWidth = containerRect.width;
|
||||||
|
const svgHeight = containerRect.height;
|
||||||
|
setSvgDimensions({ width: svgWidth, height: svgHeight });
|
||||||
|
|
||||||
|
const startX =
|
||||||
|
rectA.left - containerRect.left + rectA.width / 2 + startXOffset;
|
||||||
|
const startY =
|
||||||
|
rectA.top - containerRect.top + rectA.height / 2 + startYOffset;
|
||||||
|
const endX =
|
||||||
|
rectB.left - containerRect.left + rectB.width / 2 + endXOffset;
|
||||||
|
const endY =
|
||||||
|
rectB.top - containerRect.top + rectB.height / 2 + endYOffset;
|
||||||
|
|
||||||
|
const controlY = startY - curvature;
|
||||||
|
const d = `M ${startX},${startY} Q ${
|
||||||
|
(startX + endX) / 2
|
||||||
|
},${controlY} ${endX},${endY}`;
|
||||||
|
setPathD(d);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize ResizeObserver
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
// For all entries, recalculate the path
|
||||||
|
for (const _entry of entries) {
|
||||||
|
updatePath();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe the container element
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the updatePath initially to set the initial path
|
||||||
|
updatePath();
|
||||||
|
|
||||||
|
// Clean up the observer on component unmount
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
containerRef,
|
||||||
|
fromRef,
|
||||||
|
toRef,
|
||||||
|
curvature,
|
||||||
|
startXOffset,
|
||||||
|
startYOffset,
|
||||||
|
endXOffset,
|
||||||
|
endYOffset,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
width={svgDimensions.width}
|
||||||
|
height={svgDimensions.height}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute left-0 top-0 transform-gpu stroke-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
viewBox={`0 0 ${svgDimensions.width} ${svgDimensions.height}`}
|
||||||
|
>
|
||||||
|
<title>Animated connection beam</title>
|
||||||
|
<path
|
||||||
|
d={pathD}
|
||||||
|
stroke={pathColor}
|
||||||
|
strokeWidth={pathWidth}
|
||||||
|
strokeOpacity={pathOpacity}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d={pathD}
|
||||||
|
strokeWidth={pathWidth}
|
||||||
|
stroke={`url(#${id})`}
|
||||||
|
strokeOpacity="1"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<motion.linearGradient
|
||||||
|
className="transform-gpu"
|
||||||
|
id={id}
|
||||||
|
gradientUnits={"userSpaceOnUse"}
|
||||||
|
initial={{
|
||||||
|
x1: "0%",
|
||||||
|
x2: "0%",
|
||||||
|
y1: "0%",
|
||||||
|
y2: "0%",
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
x1: gradientCoordinates.x1,
|
||||||
|
x2: gradientCoordinates.x2,
|
||||||
|
y1: gradientCoordinates.y1,
|
||||||
|
y2: gradientCoordinates.y2,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
delay,
|
||||||
|
duration,
|
||||||
|
ease: [0.16, 1, 0.3, 1], // https://easings.net/#easeOutExpo
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
repeatDelay: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<stop stopColor={gradientStartColor} stopOpacity="0" />
|
||||||
|
<stop stopColor={gradientStartColor} />
|
||||||
|
<stop offset="32.5%" stopColor={gradientStopColor} />
|
||||||
|
<stop offset="100%" stopColor={gradientStopColor} stopOpacity="0" />
|
||||||
|
</motion.linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
109
components/magicui/animated-circular-progress-bar.tsx
Normal file
109
components/magicui/animated-circular-progress-bar.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface AnimatedCircularProgressBarProps {
|
||||||
|
max: number;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
gaugePrimaryColor: string;
|
||||||
|
gaugeSecondaryColor: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimatedCircularProgressBar({
|
||||||
|
max = 100,
|
||||||
|
min = 0,
|
||||||
|
value = 0,
|
||||||
|
gaugePrimaryColor,
|
||||||
|
gaugeSecondaryColor,
|
||||||
|
className,
|
||||||
|
}: AnimatedCircularProgressBarProps) {
|
||||||
|
const circumference = 2 * Math.PI * 45;
|
||||||
|
const percentPx = circumference / 100;
|
||||||
|
const currentPercent = Math.round(((value - min) / (max - min)) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("relative size-40 text-2xl font-semibold", className)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--circle-size": "100px",
|
||||||
|
"--circumference": circumference,
|
||||||
|
"--percent-to-px": `${percentPx}px`,
|
||||||
|
"--gap-percent": "5",
|
||||||
|
"--offset-factor": "0",
|
||||||
|
"--transition-length": "1s",
|
||||||
|
"--transition-step": "200ms",
|
||||||
|
"--delay": "0s",
|
||||||
|
"--percent-to-deg": "3.6deg",
|
||||||
|
transform: "translateZ(0)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
className="size-full"
|
||||||
|
strokeWidth="2"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
>
|
||||||
|
<title>Circular progress indicator</title>
|
||||||
|
{currentPercent <= 90 && currentPercent >= 0 && (
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="45"
|
||||||
|
strokeWidth="10"
|
||||||
|
strokeDashoffset="0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className=" opacity-100"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
stroke: gaugeSecondaryColor,
|
||||||
|
"--stroke-percent": 90 - currentPercent,
|
||||||
|
"--offset-factor-secondary": "calc(1 - var(--offset-factor))",
|
||||||
|
strokeDasharray:
|
||||||
|
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
|
||||||
|
transform:
|
||||||
|
"rotate(calc(1turn - 90deg - (var(--gap-percent) * var(--percent-to-deg) * var(--offset-factor-secondary)))) scaleY(-1)",
|
||||||
|
transition: "all var(--transition-length) ease var(--delay)",
|
||||||
|
transformOrigin:
|
||||||
|
"calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="45"
|
||||||
|
strokeWidth="10"
|
||||||
|
strokeDashoffset="0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="opacity-100"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
stroke: gaugePrimaryColor,
|
||||||
|
"--stroke-percent": currentPercent,
|
||||||
|
strokeDasharray:
|
||||||
|
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
|
||||||
|
transition:
|
||||||
|
"var(--transition-length) ease var(--delay),stroke var(--transition-length) ease var(--delay)",
|
||||||
|
transitionProperty: "stroke-dasharray,transform",
|
||||||
|
transform:
|
||||||
|
"rotate(calc(-90deg + var(--gap-percent) * var(--offset-factor) * var(--percent-to-deg)))",
|
||||||
|
transformOrigin:
|
||||||
|
"calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
data-current-value={currentPercent}
|
||||||
|
className="duration-[var(--transition-length)] delay-[var(--delay)] absolute inset-0 m-auto size-fit ease-linear animate-in fade-in"
|
||||||
|
>
|
||||||
|
{currentPercent}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
components/magicui/animated-shiny-text.tsx
Normal file
39
components/magicui/animated-shiny-text.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import type { ComponentPropsWithoutRef, CSSProperties, FC } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface AnimatedShinyTextProps
|
||||||
|
extends ComponentPropsWithoutRef<"span"> {
|
||||||
|
shimmerWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
shimmerWidth = 100,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--shiny-width": `${shimmerWidth}px`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70",
|
||||||
|
|
||||||
|
// Shine effect
|
||||||
|
"animate-shiny-text bg-clip-text bg-no-repeat [background-position:0_0] [background-size:var(--shiny-width)_100%] [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]",
|
||||||
|
|
||||||
|
// Shine gradient
|
||||||
|
"bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
|
||||||
|
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
components/magicui/aurora-text.tsx
Normal file
44
components/magicui/aurora-text.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
|
interface AuroraTextProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
colors?: string[];
|
||||||
|
speed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuroraText = memo(
|
||||||
|
({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
colors = ["#FF0080", "#7928CA", "#0070F3", "#38bdf8"],
|
||||||
|
speed = 1,
|
||||||
|
}: AuroraTextProps) => {
|
||||||
|
const gradientStyle = {
|
||||||
|
backgroundImage: `linear-gradient(135deg, ${colors.join(", ")}, ${
|
||||||
|
colors[0]
|
||||||
|
})`,
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
animationDuration: `${10 / speed}s`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`relative inline-block ${className}`}>
|
||||||
|
<span className="sr-only">{children}</span>
|
||||||
|
<span
|
||||||
|
className="relative animate-aurora bg-[length:200%_auto] bg-clip-text text-transparent"
|
||||||
|
style={gradientStyle}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
AuroraText.displayName = "AuroraText";
|
||||||
81
components/magicui/blur-fade.tsx
Normal file
81
components/magicui/blur-fade.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AnimatePresence,
|
||||||
|
type MotionProps,
|
||||||
|
motion,
|
||||||
|
type UseInViewOptions,
|
||||||
|
useInView,
|
||||||
|
type Variants,
|
||||||
|
} from "motion/react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
type MarginType = UseInViewOptions["margin"];
|
||||||
|
|
||||||
|
interface BlurFadeProps extends MotionProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
variant?: {
|
||||||
|
hidden: { y: number };
|
||||||
|
visible: { y: number };
|
||||||
|
};
|
||||||
|
duration?: number;
|
||||||
|
delay?: number;
|
||||||
|
offset?: number;
|
||||||
|
direction?: "up" | "down" | "left" | "right";
|
||||||
|
inView?: boolean;
|
||||||
|
inViewMargin?: MarginType;
|
||||||
|
blur?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlurFade({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
duration = 0.4,
|
||||||
|
delay = 0,
|
||||||
|
offset = 6,
|
||||||
|
direction = "down",
|
||||||
|
inView = false,
|
||||||
|
inViewMargin = "-50px",
|
||||||
|
blur = "6px",
|
||||||
|
...props
|
||||||
|
}: BlurFadeProps) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const inViewResult = useInView(ref, { once: true, margin: inViewMargin });
|
||||||
|
const isInView = !inView || inViewResult;
|
||||||
|
const defaultVariants: Variants = {
|
||||||
|
hidden: {
|
||||||
|
[direction === "left" || direction === "right" ? "x" : "y"]:
|
||||||
|
direction === "right" || direction === "down" ? -offset : offset,
|
||||||
|
opacity: 0,
|
||||||
|
filter: `blur(${blur})`,
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
[direction === "left" || direction === "right" ? "x" : "y"]: 0,
|
||||||
|
opacity: 1,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const combinedVariants = variant || defaultVariants;
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? "visible" : "hidden"}
|
||||||
|
exit="hidden"
|
||||||
|
variants={combinedVariants}
|
||||||
|
transition={{
|
||||||
|
delay: 0.04 + delay,
|
||||||
|
duration,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
components/magicui/border-beam.tsx
Normal file
106
components/magicui/border-beam.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type MotionStyle, motion, type Transition } from "motion/react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface BorderBeamProps {
|
||||||
|
/**
|
||||||
|
* The size of the border beam.
|
||||||
|
*/
|
||||||
|
size?: number;
|
||||||
|
/**
|
||||||
|
* The duration of the border beam.
|
||||||
|
*/
|
||||||
|
duration?: number;
|
||||||
|
/**
|
||||||
|
* The delay of the border beam.
|
||||||
|
*/
|
||||||
|
delay?: number;
|
||||||
|
/**
|
||||||
|
* The color of the border beam from.
|
||||||
|
*/
|
||||||
|
colorFrom?: string;
|
||||||
|
/**
|
||||||
|
* The color of the border beam to.
|
||||||
|
*/
|
||||||
|
colorTo?: string;
|
||||||
|
/**
|
||||||
|
* The motion transition of the border beam.
|
||||||
|
*/
|
||||||
|
transition?: Transition;
|
||||||
|
/**
|
||||||
|
* The class name of the border beam.
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* The style of the border beam.
|
||||||
|
*/
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
/**
|
||||||
|
* Whether to reverse the animation direction.
|
||||||
|
*/
|
||||||
|
reverse?: boolean;
|
||||||
|
/**
|
||||||
|
* The initial offset position (0-100).
|
||||||
|
*/
|
||||||
|
initialOffset?: number;
|
||||||
|
/**
|
||||||
|
* The border width of the beam.
|
||||||
|
*/
|
||||||
|
borderWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BorderBeam = ({
|
||||||
|
className,
|
||||||
|
size = 50,
|
||||||
|
delay = 0,
|
||||||
|
duration = 6,
|
||||||
|
colorFrom = "#ffaa40",
|
||||||
|
colorTo = "#9c40ff",
|
||||||
|
transition,
|
||||||
|
style,
|
||||||
|
reverse = false,
|
||||||
|
initialOffset = 0,
|
||||||
|
borderWidth = 1,
|
||||||
|
}: BorderBeamProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-[inherit] border-transparent [mask-clip:padding-box,border-box] [mask-composite:intersect] [mask-image:linear-gradient(transparent,transparent),linear-gradient(#000,#000)] border-(length:--border-beam-width)"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--border-beam-width": `${borderWidth}px`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
"absolute aspect-square",
|
||||||
|
"bg-gradient-to-l from-[var(--color-from)] via-[var(--color-to)] to-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
offsetPath: `rect(0 auto auto 0 round ${size}px)`,
|
||||||
|
"--color-from": colorFrom,
|
||||||
|
"--color-to": colorTo,
|
||||||
|
...style,
|
||||||
|
} as MotionStyle
|
||||||
|
}
|
||||||
|
initial={{ offsetDistance: `${initialOffset}%` }}
|
||||||
|
animate={{
|
||||||
|
offsetDistance: reverse
|
||||||
|
? [`${100 - initialOffset}%`, `${-initialOffset}%`]
|
||||||
|
: [`${initialOffset}%`, `${100 + initialOffset}%`],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
ease: "linear",
|
||||||
|
duration,
|
||||||
|
delay: -delay,
|
||||||
|
...transition,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
150
components/magicui/confetti.tsx
Normal file
150
components/magicui/confetti.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GlobalOptions as ConfettiGlobalOptions,
|
||||||
|
CreateTypes as ConfettiInstance,
|
||||||
|
Options as ConfettiOptions,
|
||||||
|
} from "canvas-confetti";
|
||||||
|
import confetti from "canvas-confetti";
|
||||||
|
import type React from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type Api = {
|
||||||
|
fire: (options?: ConfettiOptions) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = React.ComponentPropsWithRef<"canvas"> & {
|
||||||
|
options?: ConfettiOptions;
|
||||||
|
globalOptions?: ConfettiGlobalOptions;
|
||||||
|
manualstart?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfettiRef = Api | null;
|
||||||
|
|
||||||
|
const ConfettiContext = createContext<Api>({} as Api);
|
||||||
|
|
||||||
|
// Define component first
|
||||||
|
const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
|
||||||
|
const {
|
||||||
|
options,
|
||||||
|
globalOptions = { resize: true, useWorker: true },
|
||||||
|
manualstart = false,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
const instanceRef = useRef<ConfettiInstance | null>(null);
|
||||||
|
|
||||||
|
const canvasRef = useCallback(
|
||||||
|
(node: HTMLCanvasElement) => {
|
||||||
|
if (node !== null) {
|
||||||
|
if (instanceRef.current) return;
|
||||||
|
instanceRef.current = confetti.create(node, {
|
||||||
|
...globalOptions,
|
||||||
|
resize: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (instanceRef.current) {
|
||||||
|
instanceRef.current.reset();
|
||||||
|
instanceRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[globalOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fire = useCallback(
|
||||||
|
async (opts = {}) => {
|
||||||
|
try {
|
||||||
|
await instanceRef.current?.({ ...options, ...opts });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Confetti error:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[options]
|
||||||
|
);
|
||||||
|
|
||||||
|
const api = useMemo(
|
||||||
|
() => ({
|
||||||
|
fire,
|
||||||
|
}),
|
||||||
|
[fire]
|
||||||
|
);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => api, [api]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!manualstart) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await fire();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Confetti effect error:", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, [manualstart, fire]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfettiContext.Provider value={api}>
|
||||||
|
<canvas ref={canvasRef} {...rest} />
|
||||||
|
{children}
|
||||||
|
</ConfettiContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set display name immediately
|
||||||
|
ConfettiComponent.displayName = "Confetti";
|
||||||
|
|
||||||
|
// Export as Confetti
|
||||||
|
export const Confetti = ConfettiComponent;
|
||||||
|
|
||||||
|
interface ConfettiButtonProps extends ButtonProps {
|
||||||
|
options?: ConfettiOptions &
|
||||||
|
ConfettiGlobalOptions & { canvas?: HTMLCanvasElement };
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfettiButtonComponent = ({
|
||||||
|
options,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ConfettiButtonProps) => {
|
||||||
|
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
try {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const x = rect.left + rect.width / 2;
|
||||||
|
const y = rect.top + rect.height / 2;
|
||||||
|
await confetti({
|
||||||
|
...options,
|
||||||
|
origin: {
|
||||||
|
x: x / window.innerWidth,
|
||||||
|
y: y / window.innerHeight,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Confetti button error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onClick={handleClick} {...props}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ConfettiButtonComponent.displayName = "ConfettiButton";
|
||||||
|
|
||||||
|
export const ConfettiButton = ConfettiButtonComponent;
|
||||||
109
components/magicui/magic-card.tsx
Normal file
109
components/magicui/magic-card.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, useMotionTemplate, useMotionValue } from "motion/react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface MagicCardProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
gradientSize?: number;
|
||||||
|
gradientColor?: string;
|
||||||
|
gradientOpacity?: number;
|
||||||
|
gradientFrom?: string;
|
||||||
|
gradientTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MagicCard({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
gradientSize = 200,
|
||||||
|
gradientColor = "#262626",
|
||||||
|
gradientOpacity = 0.8,
|
||||||
|
gradientFrom = "#9E7AFF",
|
||||||
|
gradientTo = "#FE8BBB",
|
||||||
|
}: MagicCardProps) {
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mouseX = useMotionValue(-gradientSize);
|
||||||
|
const mouseY = useMotionValue(-gradientSize);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (cardRef.current) {
|
||||||
|
const { left, top } = cardRef.current.getBoundingClientRect();
|
||||||
|
const clientX = e.clientX;
|
||||||
|
const clientY = e.clientY;
|
||||||
|
mouseX.set(clientX - left);
|
||||||
|
mouseY.set(clientY - top);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mouseX, mouseY]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseOut = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (!e.relatedTarget) {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
mouseX.set(-gradientSize);
|
||||||
|
mouseY.set(-gradientSize);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleMouseMove, mouseX, gradientSize, mouseY]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
mouseX.set(-gradientSize);
|
||||||
|
mouseY.set(-gradientSize);
|
||||||
|
}, [handleMouseMove, mouseX, gradientSize, mouseY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseout", handleMouseOut);
|
||||||
|
document.addEventListener("mouseenter", handleMouseEnter);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseout", handleMouseOut);
|
||||||
|
document.removeEventListener("mouseenter", handleMouseEnter);
|
||||||
|
};
|
||||||
|
}, [handleMouseEnter, handleMouseMove, handleMouseOut]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mouseX.set(-gradientSize);
|
||||||
|
mouseY.set(-gradientSize);
|
||||||
|
}, [gradientSize, mouseX, mouseY]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
className={cn("group relative rounded-[inherit]", className)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-[inherit] bg-border duration-300 group-hover:opacity-100"
|
||||||
|
style={{
|
||||||
|
background: useMotionTemplate`
|
||||||
|
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
|
||||||
|
${gradientFrom},
|
||||||
|
${gradientTo},
|
||||||
|
var(--border) 100%
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-px rounded-[inherit] bg-background" />
|
||||||
|
<motion.div
|
||||||
|
className="pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||||
|
style={{
|
||||||
|
background: useMotionTemplate`
|
||||||
|
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)
|
||||||
|
`,
|
||||||
|
opacity: gradientOpacity,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
components/magicui/meteors.tsx
Normal file
61
components/magicui/meteors.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface MeteorsProps {
|
||||||
|
number?: number;
|
||||||
|
minDelay?: number;
|
||||||
|
maxDelay?: number;
|
||||||
|
minDuration?: number;
|
||||||
|
maxDuration?: number;
|
||||||
|
angle?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Meteors = ({
|
||||||
|
number = 20,
|
||||||
|
minDelay = 0.2,
|
||||||
|
maxDelay = 1.2,
|
||||||
|
minDuration = 2,
|
||||||
|
maxDuration = 10,
|
||||||
|
angle = 215,
|
||||||
|
className,
|
||||||
|
}: MeteorsProps) => {
|
||||||
|
const [meteorStyles, setMeteorStyles] = useState<Array<React.CSSProperties>>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const styles = [...new Array(number)].map(() => ({
|
||||||
|
"--angle": `${-angle}deg`,
|
||||||
|
top: "-5%",
|
||||||
|
left: `calc(0% + ${Math.floor(Math.random() * window.innerWidth)}px)`,
|
||||||
|
animationDelay: `${Math.random() * (maxDelay - minDelay) + minDelay}s`,
|
||||||
|
animationDuration:
|
||||||
|
Math.floor(Math.random() * (maxDuration - minDuration) + minDuration) +
|
||||||
|
"s",
|
||||||
|
}));
|
||||||
|
setMeteorStyles(styles);
|
||||||
|
}, [number, minDelay, maxDelay, minDuration, maxDuration, angle]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{[...meteorStyles].map((style, idx) => (
|
||||||
|
// Meteor Head
|
||||||
|
<span
|
||||||
|
key={`meteor-${style.left}-${style.animationDelay}-${idx}`}
|
||||||
|
style={{ ...style }}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute size-0.5 rotate-[var(--angle)] animate-meteor rounded-full bg-zinc-500 shadow-[0_0_0_1px_#ffffff10]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Meteor Tail */}
|
||||||
|
<div className="pointer-events-none absolute top-1/2 -z-10 h-px w-[50px] -translate-y-1/2 bg-gradient-to-r from-zinc-500 to-transparent" />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
155
components/magicui/neon-gradient-card.tsx
Normal file
155
components/magicui/neon-gradient-card.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface NeonColorsProps {
|
||||||
|
firstColor: string;
|
||||||
|
secondColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NeonGradientCardProps {
|
||||||
|
/**
|
||||||
|
* @default <div />
|
||||||
|
* @type ReactElement
|
||||||
|
* @description
|
||||||
|
* The component to be rendered as the card
|
||||||
|
* */
|
||||||
|
as?: ReactElement;
|
||||||
|
/**
|
||||||
|
* @default ""
|
||||||
|
* @type string
|
||||||
|
* @description
|
||||||
|
* The className of the card
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default ""
|
||||||
|
* @type ReactNode
|
||||||
|
* @description
|
||||||
|
* The children of the card
|
||||||
|
* */
|
||||||
|
children?: ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default 5
|
||||||
|
* @type number
|
||||||
|
* @description
|
||||||
|
* The size of the border in pixels
|
||||||
|
* */
|
||||||
|
borderSize?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default 20
|
||||||
|
* @type number
|
||||||
|
* @description
|
||||||
|
* The size of the radius in pixels
|
||||||
|
* */
|
||||||
|
borderRadius?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default "{ firstColor: '#ff00aa', secondColor: '#00FFF1' }"
|
||||||
|
* @type string
|
||||||
|
* @description
|
||||||
|
* The colors of the neon gradient
|
||||||
|
* */
|
||||||
|
neonColors?: NeonColorsProps;
|
||||||
|
|
||||||
|
// Allow additional HTML div properties
|
||||||
|
style?: CSSProperties;
|
||||||
|
id?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
onMouseEnter?: () => void;
|
||||||
|
onMouseLeave?: () => void;
|
||||||
|
"data-testid"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
borderSize = 2,
|
||||||
|
borderRadius = 20,
|
||||||
|
neonColors = {
|
||||||
|
firstColor: "#ff00aa",
|
||||||
|
secondColor: "#00FFF1",
|
||||||
|
},
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateDimensions = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const { offsetWidth, offsetHeight } = containerRef.current;
|
||||||
|
setDimensions({ width: offsetWidth, height: offsetHeight });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDimensions();
|
||||||
|
window.addEventListener("resize", updateDimensions);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", updateDimensions);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const { offsetWidth, offsetHeight } = containerRef.current;
|
||||||
|
setDimensions({ width: offsetWidth, height: offsetHeight });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--border-size": `${borderSize}px`,
|
||||||
|
"--border-radius": `${borderRadius}px`,
|
||||||
|
"--neon-first-color": neonColors.firstColor,
|
||||||
|
"--neon-second-color": neonColors.secondColor,
|
||||||
|
"--card-width": `${dimensions.width}px`,
|
||||||
|
"--card-height": `${dimensions.height}px`,
|
||||||
|
"--card-content-radius": `${borderRadius - borderSize}px`,
|
||||||
|
"--pseudo-element-background-image": `linear-gradient(0deg, ${neonColors.firstColor}, ${neonColors.secondColor})`,
|
||||||
|
"--pseudo-element-width": `${dimensions.width + borderSize * 2}px`,
|
||||||
|
"--pseudo-element-height": `${dimensions.height + borderSize * 2}px`,
|
||||||
|
"--after-blur": `${dimensions.width / 3}px`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 size-full rounded-[var(--border-radius)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative size-full min-h-[inherit] rounded-[var(--card-content-radius)] bg-gray-100 p-6",
|
||||||
|
"before:absolute before:-left-[var(--border-size)] before:-top-[var(--border-size)] before:-z-10 before:block",
|
||||||
|
"before:h-[var(--pseudo-element-height)] before:w-[var(--pseudo-element-width)] before:rounded-[var(--border-radius)] before:content-['']",
|
||||||
|
"before:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] before:bg-[length:100%_200%]",
|
||||||
|
"before:animate-background-position-spin",
|
||||||
|
"after:absolute after:-left-[var(--border-size)] after:-top-[var(--border-size)] after:-z-10 after:block",
|
||||||
|
"after:h-[var(--pseudo-element-height)] after:w-[var(--pseudo-element-width)] after:rounded-[var(--border-radius)] after:blur-[var(--after-blur)] after:content-['']",
|
||||||
|
"after:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] after:bg-[length:100%_200%] after:opacity-80",
|
||||||
|
"after:animate-background-position-spin",
|
||||||
|
"dark:bg-neutral-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
67
components/magicui/number-ticker.tsx
Normal file
67
components/magicui/number-ticker.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useInView, useMotionValue, useSpring } from "motion/react";
|
||||||
|
import { type ComponentPropsWithoutRef, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> {
|
||||||
|
value: number;
|
||||||
|
startValue?: number;
|
||||||
|
direction?: "up" | "down";
|
||||||
|
delay?: number;
|
||||||
|
decimalPlaces?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NumberTicker({
|
||||||
|
value,
|
||||||
|
startValue = 0,
|
||||||
|
direction = "up",
|
||||||
|
delay = 0,
|
||||||
|
className,
|
||||||
|
decimalPlaces = 0,
|
||||||
|
...props
|
||||||
|
}: NumberTickerProps) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const motionValue = useMotionValue(direction === "down" ? value : startValue);
|
||||||
|
const springValue = useSpring(motionValue, {
|
||||||
|
damping: 60,
|
||||||
|
stiffness: 100,
|
||||||
|
});
|
||||||
|
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInView) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
motionValue.set(direction === "down" ? startValue : value);
|
||||||
|
}, delay * 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [motionValue, isInView, delay, value, direction, startValue]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
springValue.on("change", (latest) => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.textContent = Intl.NumberFormat("en-US", {
|
||||||
|
minimumFractionDigits: decimalPlaces,
|
||||||
|
maximumFractionDigits: decimalPlaces,
|
||||||
|
}).format(Number(latest.toFixed(decimalPlaces)));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[springValue, decimalPlaces]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-block tabular-nums tracking-wider text-black dark:text-white",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{startValue}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
components/magicui/pointer.tsx
Normal file
121
components/magicui/pointer.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AnimatePresence,
|
||||||
|
type HTMLMotionProps,
|
||||||
|
motion,
|
||||||
|
useMotionValue,
|
||||||
|
} from "motion/react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface PointerProps extends Omit<HTMLMotionProps<"div">, "ref"> {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom pointer component that displays an animated cursor.
|
||||||
|
* Add this as a child to any component to enable a custom pointer when hovering.
|
||||||
|
* You can pass custom children to render as the pointer.
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @param {PointerProps} props - The component props
|
||||||
|
*/
|
||||||
|
export function Pointer({
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: PointerProps): JSX.Element {
|
||||||
|
const x = useMotionValue(0);
|
||||||
|
const y = useMotionValue(0);
|
||||||
|
const [isActive, setIsActive] = useState<boolean>(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined" && containerRef.current) {
|
||||||
|
// Get the parent element directly from the ref
|
||||||
|
const parentElement = containerRef.current.parentElement;
|
||||||
|
|
||||||
|
if (parentElement) {
|
||||||
|
// Add cursor-none to parent
|
||||||
|
parentElement.style.cursor = "none";
|
||||||
|
|
||||||
|
// Add event listeners to parent
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
x.set(e.clientX);
|
||||||
|
y.set(e.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setIsActive(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setIsActive(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
parentElement.addEventListener("mousemove", handleMouseMove);
|
||||||
|
parentElement.addEventListener("mouseenter", handleMouseEnter);
|
||||||
|
parentElement.addEventListener("mouseleave", handleMouseLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
parentElement.style.cursor = "";
|
||||||
|
parentElement.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
parentElement.removeEventListener("mouseenter", handleMouseEnter);
|
||||||
|
parentElement.removeEventListener("mouseleave", handleMouseLeave);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [x, y]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={containerRef} />
|
||||||
|
<AnimatePresence>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
className="transform-[translate(-50%,-50%)] pointer-events-none fixed z-50"
|
||||||
|
style={{
|
||||||
|
top: y,
|
||||||
|
left: x,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
initial={{
|
||||||
|
scale: 0,
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
scale: 0,
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children || (
|
||||||
|
<svg
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={cn(
|
||||||
|
"rotate-[-70deg] stroke-white text-black",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<title>Mouse pointer</title>
|
||||||
|
<path d="M14.082 2.182a.5.5 0 0 1 .103.557L8.528 15.467a.5.5 0 0 1-.917-.007L5.57 10.694.803 8.652a.5.5 0 0 1-.006-.916l12.728-5.657a.5.5 0 0 1 .556.103z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
components/magicui/scroll-progress.tsx
Normal file
33
components/magicui/scroll-progress.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type MotionProps, motion, useScroll } from "motion/react";
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ScrollProgressProps
|
||||||
|
extends Omit<React.HTMLAttributes<HTMLElement>, keyof MotionProps> {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScrollProgress = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ScrollProgressProps
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { scrollYProgress } = useScroll();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-x-0 top-0 z-50 h-px origin-left bg-gradient-to-r from-[#A97CF8] via-[#F38CB8] to-[#FDCC92]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
scaleX: scrollYProgress,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ScrollProgress.displayName = "ScrollProgress";
|
||||||
64
components/magicui/shine-border.tsx
Normal file
64
components/magicui/shine-border.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/**
|
||||||
|
* Width of the border in pixels
|
||||||
|
* @default 1
|
||||||
|
*/
|
||||||
|
borderWidth?: number;
|
||||||
|
/**
|
||||||
|
* Duration of the animation in seconds
|
||||||
|
* @default 14
|
||||||
|
*/
|
||||||
|
duration?: number;
|
||||||
|
/**
|
||||||
|
* Color of the border, can be a single color or an array of colors
|
||||||
|
* @default "#000000"
|
||||||
|
*/
|
||||||
|
shineColor?: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shine Border
|
||||||
|
*
|
||||||
|
* An animated background border effect component with configurable properties.
|
||||||
|
*/
|
||||||
|
export function ShineBorder({
|
||||||
|
borderWidth = 1,
|
||||||
|
duration = 14,
|
||||||
|
shineColor = "#000000",
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: ShineBorderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--border-width": `${borderWidth}px`,
|
||||||
|
"--duration": `${duration}s`,
|
||||||
|
backgroundImage: `radial-gradient(transparent,transparent, ${
|
||||||
|
Array.isArray(shineColor) ? shineColor.join(",") : shineColor
|
||||||
|
},transparent,transparent)`,
|
||||||
|
backgroundSize: "300% 300%",
|
||||||
|
mask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
||||||
|
WebkitMask:
|
||||||
|
"linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
||||||
|
WebkitMaskComposite: "xor",
|
||||||
|
maskComposite: "exclude",
|
||||||
|
padding: "var(--border-width)",
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position] motion-safe:animate-shine",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
414
components/magicui/text-animate.tsx
Normal file
414
components/magicui/text-animate.tsx
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AnimatePresence,
|
||||||
|
type MotionProps,
|
||||||
|
motion,
|
||||||
|
type Variants,
|
||||||
|
} from "motion/react";
|
||||||
|
import { type ElementType, memo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type AnimationType = "text" | "word" | "character" | "line";
|
||||||
|
type AnimationVariant =
|
||||||
|
| "fadeIn"
|
||||||
|
| "blurIn"
|
||||||
|
| "blurInUp"
|
||||||
|
| "blurInDown"
|
||||||
|
| "slideUp"
|
||||||
|
| "slideDown"
|
||||||
|
| "slideLeft"
|
||||||
|
| "slideRight"
|
||||||
|
| "scaleUp"
|
||||||
|
| "scaleDown";
|
||||||
|
|
||||||
|
interface TextAnimateProps extends MotionProps {
|
||||||
|
/**
|
||||||
|
* The text content to animate
|
||||||
|
*/
|
||||||
|
children: string;
|
||||||
|
/**
|
||||||
|
* The class name to be applied to the component
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* The class name to be applied to each segment
|
||||||
|
*/
|
||||||
|
segmentClassName?: string;
|
||||||
|
/**
|
||||||
|
* The delay before the animation starts
|
||||||
|
*/
|
||||||
|
delay?: number;
|
||||||
|
/**
|
||||||
|
* The duration of the animation
|
||||||
|
*/
|
||||||
|
duration?: number;
|
||||||
|
/**
|
||||||
|
* Custom motion variants for the animation
|
||||||
|
*/
|
||||||
|
variants?: Variants;
|
||||||
|
/**
|
||||||
|
* The element type to render
|
||||||
|
*/
|
||||||
|
as?: ElementType;
|
||||||
|
/**
|
||||||
|
* How to split the text ("text", "word", "character")
|
||||||
|
*/
|
||||||
|
by?: AnimationType;
|
||||||
|
/**
|
||||||
|
* Whether to start animation when component enters viewport
|
||||||
|
*/
|
||||||
|
startOnView?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to animate only once
|
||||||
|
*/
|
||||||
|
once?: boolean;
|
||||||
|
/**
|
||||||
|
* The animation preset to use
|
||||||
|
*/
|
||||||
|
animation?: AnimationVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staggerTimings: Record<AnimationType, number> = {
|
||||||
|
text: 0.06,
|
||||||
|
word: 0.05,
|
||||||
|
character: 0.03,
|
||||||
|
line: 0.06,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultContainerVariants = {
|
||||||
|
hidden: { opacity: 1 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
delayChildren: 0,
|
||||||
|
staggerChildren: 0.05,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.05,
|
||||||
|
staggerDirection: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultItemVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultItemAnimationVariants: Record<
|
||||||
|
AnimationVariant,
|
||||||
|
{ container: Variants; item: Variants }
|
||||||
|
> = {
|
||||||
|
fadeIn: {
|
||||||
|
container: defaultContainerVariants,
|
||||||
|
item: {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
y: 20,
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blurIn: {
|
||||||
|
container: defaultContainerVariants,
|
||||||
|
item: {
|
||||||
|
hidden: { opacity: 0, filter: "blur(10px)" },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
filter: "blur(10px)",
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blurInUp: {
|
||||||
|
container: defaultContainerVariants,
|
||||||
|
item: {
|
||||||
|
hidden: { opacity: 0, filter: "blur(10px)", y: 20 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
y: { duration: 0.3 },
|
||||||
|
opacity: { duration: 0.4 },
|
||||||
|
filter: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
filter: "blur(10px)",
|
||||||
|
y: 20,
|
||||||
|
transition: {
|
||||||
|
y: { duration: 0.3 },
|
||||||
|
opacity: { duration: 0.4 },
|
||||||
|
filter: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blurInDown: {
|
||||||
|
container: defaultContainerVariants,
|
||||||
|
item: {
|
||||||
|
hidden: { opacity: 0, filter: "blur(10px)", y: -20 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
y: { duration: 0.3 },
|
||||||
|
opacity: { duration: 0.4 },
|
||||||
|
filter: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
container: defaultContainerVariants,
|
||||||
|
item: {
|
||||||
|
hidden: { y: 20, opacity: 0 },
|
||||||
|
show: {
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
y: -20,
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slideDown: {
|
||||||
|
container: defaultContainerVariants,
|
||||||
|
item: {
|
||||||
|
hidden: { y: -20, opacity: 0 },
|
||||||
|
show: {
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
y: 20,
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slideLeft: {
|
||||||
|
container: defaultContainerVariants,
|
||||||
|
item: {
|
||||||
|
hidden: { x: 20, opacity: 0 },
|
||||||
|
show: {
|
||||||
|
x: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
x: -20,
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slideRight: {
|
||||||
|
container: defaultContainerVariants,
|
||||||
|
item: {
|
||||||
|
hidden: { x: -20, opacity: 0 },
|
||||||
|
show: {
|
||||||
|
x: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
x: 20,
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scaleUp: {
|
||||||
|
container: defaultContainerVariants,
|
||||||
|
item: {
|
||||||
|
hidden: { scale: 0.5, opacity: 0 },
|
||||||
|
show: {
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
scale: {
|
||||||
|
type: "spring",
|
||||||
|
damping: 15,
|
||||||
|
stiffness: 300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
scale: 0.5,
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scaleDown: {
|
||||||
|
container: defaultContainerVariants,
|
||||||
|
item: {
|
||||||
|
hidden: { scale: 1.5, opacity: 0 },
|
||||||
|
show: {
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
scale: {
|
||||||
|
type: "spring",
|
||||||
|
damping: 15,
|
||||||
|
stiffness: 300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
scale: 1.5,
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const TextAnimateBase = ({
|
||||||
|
children,
|
||||||
|
delay = 0,
|
||||||
|
duration = 0.3,
|
||||||
|
variants,
|
||||||
|
className,
|
||||||
|
segmentClassName,
|
||||||
|
as: Component = "p",
|
||||||
|
startOnView = true,
|
||||||
|
once = false,
|
||||||
|
by = "word",
|
||||||
|
animation = "fadeIn",
|
||||||
|
...props
|
||||||
|
}: TextAnimateProps) => {
|
||||||
|
const MotionComponent = motion.create(Component);
|
||||||
|
|
||||||
|
let segments: string[] = [];
|
||||||
|
switch (by) {
|
||||||
|
case "word":
|
||||||
|
segments = children.split(/(\s+)/);
|
||||||
|
break;
|
||||||
|
case "character":
|
||||||
|
segments = children.split("");
|
||||||
|
break;
|
||||||
|
case "line":
|
||||||
|
segments = children.split("\n");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
segments = [children];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalVariants = variants
|
||||||
|
? {
|
||||||
|
container: {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
opacity: { duration: 0.01, delay },
|
||||||
|
delayChildren: delay,
|
||||||
|
staggerChildren: duration / segments.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: duration / segments.length,
|
||||||
|
staggerDirection: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
item: variants,
|
||||||
|
}
|
||||||
|
: animation
|
||||||
|
? {
|
||||||
|
container: {
|
||||||
|
...defaultItemAnimationVariants[animation].container,
|
||||||
|
show: {
|
||||||
|
...defaultItemAnimationVariants[animation].container.show,
|
||||||
|
transition: {
|
||||||
|
delayChildren: delay,
|
||||||
|
staggerChildren: duration / segments.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
...defaultItemAnimationVariants[animation].container.exit,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: duration / segments.length,
|
||||||
|
staggerDirection: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
item: defaultItemAnimationVariants[animation].item,
|
||||||
|
}
|
||||||
|
: { container: defaultContainerVariants, item: defaultItemVariants };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
<MotionComponent
|
||||||
|
variants={finalVariants.container as Variants}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView={startOnView ? "show" : undefined}
|
||||||
|
animate={startOnView ? undefined : "show"}
|
||||||
|
exit="exit"
|
||||||
|
className={cn("whitespace-pre-wrap", className)}
|
||||||
|
viewport={{ once }}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{segments.map((segment, i) => (
|
||||||
|
<motion.span
|
||||||
|
key={`${by}-${segment.replace(/\s/g, "_")}-${i}-${segment.length}`}
|
||||||
|
variants={finalVariants.item}
|
||||||
|
custom={i * staggerTimings[by]}
|
||||||
|
className={cn(
|
||||||
|
by === "line" ? "block" : "inline-block whitespace-pre",
|
||||||
|
by === "character" && "",
|
||||||
|
segmentClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{segment}
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
</MotionComponent>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export the memoized version
|
||||||
|
export const TextAnimate = memo(TextAnimateBase);
|
||||||
85
components/magicui/text-reveal.tsx
Normal file
85
components/magicui/text-reveal.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type MotionValue,
|
||||||
|
motion,
|
||||||
|
useScroll,
|
||||||
|
useTransform,
|
||||||
|
} from "motion/react";
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type FC,
|
||||||
|
type ReactNode,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface TextRevealProps extends ComponentPropsWithoutRef<"div"> {
|
||||||
|
children: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextReveal: FC<TextRevealProps> = ({ children, className }) => {
|
||||||
|
const targetRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const { scrollYProgress } = useScroll({
|
||||||
|
target: targetRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof children !== "string") {
|
||||||
|
throw new Error("TextReveal: children must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = children.split(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={targetRef} className={cn("relative z-0 h-[200vh]", className)}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"sticky top-0 mx-auto flex h-[50%] max-w-4xl items-center bg-transparent px-[1rem] py-[5rem]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
ref={targetRef}
|
||||||
|
className={
|
||||||
|
"flex flex-wrap p-5 text-2xl font-bold text-black/20 dark:text-white/20 md:p-8 md:text-3xl lg:p-10 lg:text-4xl xl:text-5xl"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{words.map((word, i) => {
|
||||||
|
const start = i / words.length;
|
||||||
|
const end = start + 1 / words.length;
|
||||||
|
return (
|
||||||
|
<Word
|
||||||
|
key={`word-${word}-${i}-${start}`}
|
||||||
|
progress={scrollYProgress}
|
||||||
|
range={[start, end]}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</Word>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WordProps {
|
||||||
|
children: ReactNode;
|
||||||
|
progress: MotionValue<number>;
|
||||||
|
range: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Word: FC<WordProps> = ({ children, progress, range }) => {
|
||||||
|
const opacity = useTransform(progress, range, [0, 1]);
|
||||||
|
return (
|
||||||
|
<span className="xl:lg-3 relative mx-1 lg:mx-1.5">
|
||||||
|
<span className="absolute opacity-30">{children}</span>
|
||||||
|
<motion.span
|
||||||
|
style={{ opacity: opacity }}
|
||||||
|
className={"text-black dark:text-white"}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
8
components/theme-provider.tsx
Normal file
8
components/theme-provider.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
import type { ThemeProviderProps } from "next-themes/dist/types";
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
66
components/ui/accordion.tsx
Normal file
66
components/ui/accordion.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
156
components/ui/alert-dialog.tsx
Normal file
156
components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
59
components/ui/alert.tsx
Normal file
59
components/ui/alert.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Alert.displayName = "Alert";
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertTitle.displayName = "AlertTitle";
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDescription.displayName = "AlertDescription";
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user