simikkk commited on
Commit
e218aaf
·
verified ·
1 Parent(s): 9d814f6

Upload 86 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +29 -0
  2. Dockerfile +51 -0
  3. components.json +21 -0
  4. eslint.config.mjs +50 -0
  5. next.config.ts +12 -0
  6. package.json +100 -0
  7. postcss.config.mjs +5 -0
  8. prisma/schema.prisma +80 -0
  9. public/logo.svg +29 -0
  10. public/robots.txt +14 -0
  11. src/app/api/activity/route.ts +24 -0
  12. src/app/api/auth/login/route.ts +89 -0
  13. src/app/api/auth/logout/route.ts +30 -0
  14. src/app/api/auth/me/route.ts +33 -0
  15. src/app/api/auth/password/route.ts +81 -0
  16. src/app/api/auth/signup/route.ts +98 -0
  17. src/app/api/chat/route.ts +215 -0
  18. src/app/api/invoices/export/csv/route.ts +89 -0
  19. src/app/api/invoices/export/json/route.ts +63 -0
  20. src/app/api/invoices/export/xlsx/route.ts +144 -0
  21. src/app/api/invoices/route.ts +103 -0
  22. src/app/api/invoices/update/route.ts +45 -0
  23. src/app/api/route.ts +5 -0
  24. src/app/api/seed/route.ts +364 -0
  25. src/app/globals.css +2040 -0
  26. src/app/layout.tsx +59 -0
  27. src/app/page.tsx +0 -0
  28. src/components/ui/accordion.tsx +66 -0
  29. src/components/ui/alert-dialog.tsx +157 -0
  30. src/components/ui/alert.tsx +66 -0
  31. src/components/ui/aspect-ratio.tsx +11 -0
  32. src/components/ui/avatar.tsx +53 -0
  33. src/components/ui/badge.tsx +46 -0
  34. src/components/ui/breadcrumb.tsx +109 -0
  35. src/components/ui/button.tsx +59 -0
  36. src/components/ui/calendar.tsx +213 -0
  37. src/components/ui/card.tsx +92 -0
  38. src/components/ui/carousel.tsx +241 -0
  39. src/components/ui/chart.tsx +353 -0
  40. src/components/ui/checkbox.tsx +32 -0
  41. src/components/ui/collapsible.tsx +33 -0
  42. src/components/ui/command.tsx +184 -0
  43. src/components/ui/context-menu.tsx +252 -0
  44. src/components/ui/dialog.tsx +143 -0
  45. src/components/ui/drawer.tsx +135 -0
  46. src/components/ui/dropdown-menu.tsx +257 -0
  47. src/components/ui/form.tsx +167 -0
  48. src/components/ui/hover-card.tsx +44 -0
  49. src/components/ui/input-otp.tsx +77 -0
  50. src/components/ui/input.tsx +21 -0
.dockerignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .next
3
+ .git
4
+ .gitignore
5
+ db/
6
+ *.db
7
+ *.log
8
+ dev.log
9
+ server.log
10
+ tool-results/
11
+ download/
12
+ hf-deploy/
13
+ examples/
14
+ agent-ctx/
15
+ vercel/
16
+ --output
17
+ .env
18
+ *.png
19
+ *.jpg
20
+ *.jpeg
21
+ claude*
22
+ .z-ai-config
23
+ .claude
24
+ Caddyfile
25
+ hf_*.py
26
+ hf_*.json
27
+ bun.lock
28
+ README.md
29
+ worklog.md
Dockerfile ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Stage 1: Install dependencies ─────────────────────────────────────
2
+ FROM node:20-alpine AS deps
3
+ WORKDIR /app
4
+
5
+ # Copy package.json AND prisma schema (postinstall needs schema)
6
+ COPY package.json ./
7
+ COPY prisma ./prisma/
8
+
9
+ # Install deps without scripts (prisma generate runs in builder stage)
10
+ RUN npm install --ignore-scripts 2>/dev/null
11
+
12
+ # ── Stage 2: Build the Next.js app ────────────────────────────────────
13
+ FROM node:20-alpine AS builder
14
+ WORKDIR /app
15
+
16
+ COPY --from=deps /app/node_modules ./node_modules
17
+ COPY . .
18
+
19
+ # Generate Prisma client then build
20
+ RUN npx prisma generate
21
+ RUN npm run build
22
+
23
+ # ── Stage 3: Production ───────────────────────────────────────────────
24
+ FROM node:20-alpine AS runner
25
+ WORKDIR /app
26
+
27
+ ENV NODE_ENV=production
28
+ ENV PORT=7860
29
+ ENV HOSTNAME="0.0.0.0"
30
+
31
+ # Create db directory for SQLite
32
+ RUN mkdir -p /app/db
33
+
34
+ # Copy standalone output
35
+ COPY --from=builder /app/.next/standalone ./
36
+ COPY --from=builder /app/.next/static ./.next/static
37
+ COPY --from=builder /app/public ./public
38
+
39
+ # Copy Prisma schema + engine for db push on startup
40
+ COPY --from=builder /app/prisma ./prisma
41
+ COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
42
+ COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
43
+ COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
44
+
45
+ EXPOSE 7860
46
+
47
+ # Entrypoint: init DB then start server
48
+ RUN printf '#!/bin/sh\nnpx prisma db push --skip-generate 2>&1 || true\nexec node /app/server.js\n' > /app/entrypoint.sh \
49
+ && chmod +x /app/entrypoint.sh
50
+
51
+ CMD ["/app/entrypoint.sh"]
components.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/app/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils",
16
+ "ui": "@/components/ui",
17
+ "lib": "@/lib",
18
+ "hooks": "@/hooks"
19
+ },
20
+ "iconLibrary": "lucide"
21
+ }
eslint.config.mjs ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
2
+ import nextTypescript from "eslint-config-next/typescript";
3
+ import { dirname } from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+
9
+ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
10
+ rules: {
11
+ // TypeScript rules
12
+ "@typescript-eslint/no-explicit-any": "off",
13
+ "@typescript-eslint/no-unused-vars": "off",
14
+ "@typescript-eslint/no-non-null-assertion": "off",
15
+ "@typescript-eslint/ban-ts-comment": "off",
16
+ "@typescript-eslint/prefer-as-const": "off",
17
+ "@typescript-eslint/no-unused-disable-directive": "off",
18
+
19
+ // React rules
20
+ "react-hooks/exhaustive-deps": "off",
21
+ "react-hooks/purity": "off",
22
+ "react/no-unescaped-entities": "off",
23
+ "react/display-name": "off",
24
+ "react/prop-types": "off",
25
+ "react-compiler/react-compiler": "off",
26
+
27
+ // Next.js rules
28
+ "@next/next/no-img-element": "off",
29
+ "@next/next/no-html-link-for-pages": "off",
30
+
31
+ // General JavaScript rules
32
+ "prefer-const": "off",
33
+ "no-unused-vars": "off",
34
+ "no-console": "off",
35
+ "no-debugger": "off",
36
+ "no-empty": "off",
37
+ "no-irregular-whitespace": "off",
38
+ "no-case-declarations": "off",
39
+ "no-fallthrough": "off",
40
+ "no-mixed-spaces-and-tabs": "off",
41
+ "no-redeclare": "off",
42
+ "no-undef": "off",
43
+ "no-unreachable": "off",
44
+ "no-useless-escape": "off",
45
+ },
46
+ }, {
47
+ ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills"]
48
+ }];
49
+
50
+ export default eslintConfig;
next.config.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ output: "standalone",
5
+ /* config options here */
6
+ typescript: {
7
+ ignoreBuildErrors: true,
8
+ },
9
+ reactStrictMode: false,
10
+ };
11
+
12
+ export default nextConfig;
package.json ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "nextjs_tailwind_shadcn_ts",
3
+ "version": "0.2.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev -p 3000 2>&1 | tee dev.log",
7
+ "build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
8
+ "start": "NODE_ENV=production node .next/standalone/server.js",
9
+ "start:bun": "NODE_ENV=production bun .next/standalone/server.js",
10
+ "lint": "eslint .",
11
+ "db:push": "prisma db push",
12
+ "db:generate": "prisma generate",
13
+ "db:migrate": "prisma migrate dev",
14
+ "db:reset": "prisma migrate reset",
15
+ "postinstall": "prisma generate",
16
+ "koyeb:build": "prisma generate && next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
17
+ "koyeb:start": "npx prisma db push --skip-generate 2>/dev/null; NODE_ENV=production node .next/standalone/server.js"
18
+ },
19
+ "dependencies": {
20
+ "@dnd-kit/core": "^6.3.1",
21
+ "@dnd-kit/sortable": "^10.0.0",
22
+ "@dnd-kit/utilities": "^3.2.2",
23
+ "@hookform/resolvers": "^5.1.1",
24
+ "@mdxeditor/editor": "^3.39.1",
25
+ "@prisma/client": "^6.11.1",
26
+ "@radix-ui/react-accordion": "^1.2.11",
27
+ "@radix-ui/react-alert-dialog": "^1.1.14",
28
+ "@radix-ui/react-aspect-ratio": "^1.1.7",
29
+ "@radix-ui/react-avatar": "^1.1.10",
30
+ "@radix-ui/react-checkbox": "^1.3.2",
31
+ "@radix-ui/react-collapsible": "^1.1.11",
32
+ "@radix-ui/react-context-menu": "^2.2.15",
33
+ "@radix-ui/react-dialog": "^1.1.14",
34
+ "@radix-ui/react-dropdown-menu": "^2.1.15",
35
+ "@radix-ui/react-hover-card": "^1.1.14",
36
+ "@radix-ui/react-label": "^2.1.7",
37
+ "@radix-ui/react-menubar": "^1.1.15",
38
+ "@radix-ui/react-navigation-menu": "^1.2.13",
39
+ "@radix-ui/react-popover": "^1.1.14",
40
+ "@radix-ui/react-progress": "^1.1.7",
41
+ "@radix-ui/react-radio-group": "^1.3.7",
42
+ "@radix-ui/react-scroll-area": "^1.2.9",
43
+ "@radix-ui/react-select": "^2.2.5",
44
+ "@radix-ui/react-separator": "^1.1.7",
45
+ "@radix-ui/react-slider": "^1.3.5",
46
+ "@radix-ui/react-slot": "^1.2.3",
47
+ "@radix-ui/react-switch": "^1.2.5",
48
+ "@radix-ui/react-tabs": "^1.1.12",
49
+ "@radix-ui/react-toast": "^1.2.14",
50
+ "@radix-ui/react-toggle": "^1.1.9",
51
+ "@radix-ui/react-toggle-group": "^1.1.10",
52
+ "@radix-ui/react-tooltip": "^1.2.7",
53
+ "@reactuses/core": "^6.0.5",
54
+ "@tanstack/react-query": "^5.82.0",
55
+ "@tanstack/react-table": "^8.21.3",
56
+ "bcryptjs": "^3.0.3",
57
+ "class-variance-authority": "^0.7.1",
58
+ "clsx": "^2.1.1",
59
+ "cmdk": "^1.1.1",
60
+ "date-fns": "^4.1.0",
61
+ "embla-carousel-react": "^8.6.0",
62
+ "exceljs": "^4.4.0",
63
+ "framer-motion": "^12.23.2",
64
+ "input-otp": "^1.4.2",
65
+ "lucide-react": "^0.525.0",
66
+ "next": "^16.1.1",
67
+ "next-auth": "^4.24.11",
68
+ "next-intl": "^4.3.4",
69
+ "next-themes": "^0.4.6",
70
+ "prisma": "^6.11.1",
71
+ "react": "^19.0.0",
72
+ "react-day-picker": "^9.8.0",
73
+ "react-dom": "^19.0.0",
74
+ "react-hook-form": "^7.60.0",
75
+ "react-markdown": "^10.1.0",
76
+ "react-resizable-panels": "^3.0.3",
77
+ "react-syntax-highlighter": "^15.6.1",
78
+ "recharts": "^2.15.4",
79
+ "sharp": "^0.34.3",
80
+ "sonner": "^2.0.6",
81
+ "tailwind-merge": "^3.3.1",
82
+ "tailwindcss-animate": "^1.0.7",
83
+ "uuid": "^11.1.0",
84
+ "vaul": "^1.1.2",
85
+ "zod": "^4.4.3",
86
+ "zustand": "^5.0.6"
87
+ },
88
+ "devDependencies": {
89
+ "@tailwindcss/postcss": "^4",
90
+ "@types/bcryptjs": "^3.0.0",
91
+ "@types/react": "^19",
92
+ "@types/react-dom": "^19",
93
+ "@types/uuid": "^11.0.0",
94
+ "eslint": "^9",
95
+ "eslint-config-next": "^16.1.1",
96
+ "tailwindcss": "^4",
97
+ "tw-animate-css": "^1.3.5",
98
+ "typescript": "^5"
99
+ }
100
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: ["@tailwindcss/postcss"],
3
+ };
4
+
5
+ export default config;
prisma/schema.prisma ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ generator client {
2
+ provider = "prisma-client-js"
3
+ }
4
+
5
+ datasource db {
6
+ provider = "sqlite"
7
+ url = "file:../db/custom.db"
8
+ }
9
+
10
+ model User {
11
+ id String @id @default(cuid())
12
+ email String @unique
13
+ name String
14
+ password String
15
+ plan String @default("free")
16
+ stripeCid String?
17
+ apiKey String?
18
+ createdAt DateTime @default(now())
19
+ updatedAt DateTime @updatedAt
20
+
21
+ sessions Session[]
22
+ invoices Invoice[]
23
+ activityLogs ActivityLog[]
24
+ }
25
+
26
+ model Session {
27
+ id String @id @default(cuid())
28
+ token String @unique
29
+ userId String
30
+ expiresAt DateTime
31
+ createdAt DateTime @default(now())
32
+
33
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
34
+ }
35
+
36
+ model Invoice {
37
+ id String @id @default(cuid())
38
+ userId String
39
+ filename String?
40
+ vendor String?
41
+ invNumber String?
42
+ invDate String?
43
+ dueDate String?
44
+ amount Float?
45
+ vatAmount Float?
46
+ total Float?
47
+ currency String @default("USD")
48
+ status String @default("done")
49
+ isDuplicate Boolean @default(false)
50
+ confidence Float?
51
+ rawJson String?
52
+ createdAt DateTime @default(now())
53
+ updatedAt DateTime @updatedAt
54
+
55
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
56
+ }
57
+
58
+ model RateLimit {
59
+ id String @id @default(cuid())
60
+ key String @unique
61
+ count Int @default(0)
62
+ resetAt DateTime
63
+ createdAt DateTime @default(now())
64
+ updatedAt DateTime @updatedAt
65
+ }
66
+
67
+ model ActivityLog {
68
+ id String @id @default(cuid())
69
+ userId String
70
+ type String
71
+ title String
72
+ description String
73
+ metadata String?
74
+ createdAt DateTime @default(now())
75
+ updatedAt DateTime @updatedAt
76
+
77
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
78
+
79
+ @@index([userId])
80
+ }
public/logo.svg ADDED
public/robots.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ User-agent: Googlebot
2
+ Allow: /
3
+
4
+ User-agent: Bingbot
5
+ Allow: /
6
+
7
+ User-agent: Twitterbot
8
+ Allow: /
9
+
10
+ User-agent: facebookexternalhit
11
+ Allow: /
12
+
13
+ User-agent: *
14
+ Allow: /
src/app/api/activity/route.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { db } from '@/lib/db';
3
+ import { getCookieToken, getSessionUser } from '@/lib/auth';
4
+
5
+ export async function GET(req: NextRequest) {
6
+ try {
7
+ const token = getCookieToken(req);
8
+ if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
9
+
10
+ const user = await getSessionUser(token);
11
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12
+
13
+ const logs = await db.activityLog.findMany({
14
+ where: { userId: user.id },
15
+ orderBy: { createdAt: 'desc' },
16
+ take: 50,
17
+ });
18
+
19
+ return NextResponse.json({ activities: logs });
20
+ } catch (error) {
21
+ console.error('Activity fetch error:', error);
22
+ return NextResponse.json({ error: 'Internal error' }, { status: 500 });
23
+ }
24
+ }
src/app/api/auth/login/route.ts ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { db } from '@/lib/db';
3
+ import { loginSchema, getClientIp } from '@/lib/validation';
4
+ import {
5
+ verifyPassword,
6
+ createSession,
7
+ SESSION_COOKIE_NAME,
8
+ } from '@/lib/auth';
9
+ import { authRateLimit } from '@/lib/rate-limit';
10
+ import { logActivity } from '@/lib/activity';
11
+
12
+ export async function POST(request: NextRequest) {
13
+ try {
14
+ // ── Rate Limit ────────────────────────────────────────────────────
15
+ const ip = getClientIp(request);
16
+ const { allowed, retryAfterMs } = await authRateLimit(`auth:${ip}`);
17
+ if (!allowed) {
18
+ return NextResponse.json(
19
+ { error: 'Too many login attempts. Please try again later.' },
20
+ {
21
+ status: 429,
22
+ headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) },
23
+ }
24
+ );
25
+ }
26
+
27
+ // ── Parse & Validate Body ─────────────────────────────────────────
28
+ const body = await request.json();
29
+ const parsed = loginSchema.safeParse(body);
30
+ if (!parsed.success) {
31
+ return NextResponse.json(
32
+ { error: parsed.error.issues[0].message },
33
+ { status: 400 }
34
+ );
35
+ }
36
+
37
+ const { email, password } = parsed.data;
38
+
39
+ // ── Find User ─────────────────────────────────────────────────────
40
+ const user = await db.user.findUnique({ where: { email } });
41
+ if (!user) {
42
+ return NextResponse.json(
43
+ { error: 'Invalid email or password' },
44
+ { status: 401 }
45
+ );
46
+ }
47
+
48
+ // ── Verify Password ───────────────────────────────────────────────
49
+ const valid = await verifyPassword(password, user.password);
50
+ if (!valid) {
51
+ return NextResponse.json(
52
+ { error: 'Invalid email or password' },
53
+ { status: 401 }
54
+ );
55
+ }
56
+
57
+ // ── Create Session ────────────────────────────────────────────────
58
+ const token = await createSession(user.id);
59
+
60
+ // ── Set Cookie & Respond ──────────────────────────────────────────
61
+ const response = NextResponse.json({
62
+ user: {
63
+ id: user.id,
64
+ email: user.email,
65
+ name: user.name,
66
+ plan: user.plan,
67
+ createdAt: user.createdAt,
68
+ },
69
+ });
70
+
71
+ response.cookies.set(SESSION_COOKIE_NAME, token, {
72
+ httpOnly: true,
73
+ secure: true,
74
+ sameSite: 'lax',
75
+ path: '/',
76
+ maxAge: 7 * 24 * 60 * 60, // 7 days
77
+ });
78
+
79
+ logActivity(user.id, 'login', 'Logged in', 'Signed in successfully');
80
+
81
+ return response;
82
+ } catch (error) {
83
+ console.error('[LOGIN_ERROR]', error);
84
+ return NextResponse.json(
85
+ { error: 'Internal server error' },
86
+ { status: 500 }
87
+ );
88
+ }
89
+ }
src/app/api/auth/logout/route.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { getCookieToken, deleteSession, SESSION_COOKIE_NAME } from '@/lib/auth';
3
+
4
+ export async function POST(request: NextRequest) {
5
+ try {
6
+ const token = getCookieToken(request);
7
+
8
+ if (token) {
9
+ await deleteSession(token);
10
+ }
11
+
12
+ const response = NextResponse.json({ success: true });
13
+
14
+ response.cookies.set(SESSION_COOKIE_NAME, '', {
15
+ httpOnly: true,
16
+ secure: true,
17
+ sameSite: 'lax',
18
+ path: '/',
19
+ maxAge: 0,
20
+ });
21
+
22
+ return response;
23
+ } catch (error) {
24
+ console.error('[LOGOUT_ERROR]', error);
25
+ return NextResponse.json(
26
+ { error: 'Internal server error' },
27
+ { status: 500 }
28
+ );
29
+ }
30
+ }
src/app/api/auth/me/route.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { getCookieToken, getSessionUser } from '@/lib/auth';
3
+
4
+ export async function GET(request: NextRequest) {
5
+ try {
6
+ const token = getCookieToken(request);
7
+ if (!token) {
8
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
9
+ }
10
+
11
+ const user = await getSessionUser(token);
12
+ if (!user) {
13
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
14
+ }
15
+
16
+ return NextResponse.json({
17
+ user: {
18
+ id: user.id,
19
+ email: user.email,
20
+ name: user.name,
21
+ plan: user.plan,
22
+ apiKey: user.apiKey,
23
+ createdAt: user.createdAt,
24
+ },
25
+ });
26
+ } catch (error) {
27
+ console.error('[ME_ERROR]', error);
28
+ return NextResponse.json(
29
+ { error: 'Internal server error' },
30
+ { status: 500 }
31
+ );
32
+ }
33
+ }
src/app/api/auth/password/route.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { db } from '@/lib/db';
3
+ import { changePasswordSchema, getClientIp } from '@/lib/validation';
4
+ import { getSessionUser, verifyPassword, hashPassword, getCookieToken } from '@/lib/auth';
5
+ import { rateLimit } from '@/lib/rate-limit';
6
+ import { logActivity } from '@/lib/activity';
7
+
8
+ export async function POST(request: NextRequest) {
9
+ try {
10
+ // ── Rate Limit ────────────────────────────────────────────────────
11
+ const ip = getClientIp(request);
12
+ const { allowed, retryAfterMs } = await rateLimit(`password:${ip}`, 3, 60_000);
13
+ if (!allowed) {
14
+ return NextResponse.json(
15
+ { error: 'Too many password change attempts. Please try again later.' },
16
+ {
17
+ status: 429,
18
+ headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) },
19
+ }
20
+ );
21
+ }
22
+
23
+ // ── Authenticate User ─────────────────────────────────────────────
24
+ const token = getCookieToken(request);
25
+ const user = await getSessionUser(token || '');
26
+ if (!user) {
27
+ return NextResponse.json(
28
+ { error: 'Authentication required' },
29
+ { status: 401 }
30
+ );
31
+ }
32
+
33
+ // ── Parse & Validate Body ─────────────────────────────────────────
34
+ const body = await request.json();
35
+ const parsed = changePasswordSchema.safeParse(body);
36
+ if (!parsed.success) {
37
+ return NextResponse.json(
38
+ { error: parsed.error.issues[0].message },
39
+ { status: 400 }
40
+ );
41
+ }
42
+
43
+ const { currentPassword, newPassword } = parsed.data;
44
+
45
+ // ── Verify Current Password ───────────────────────────────────────
46
+ const valid = await verifyPassword(currentPassword, user.password);
47
+ if (!valid) {
48
+ return NextResponse.json(
49
+ { error: 'Invalid credentials' },
50
+ { status: 401 }
51
+ );
52
+ }
53
+
54
+ // ── Hash New Password ─────────────────────────────────────────────
55
+ const hashedPassword = await hashPassword(newPassword);
56
+
57
+ // ── Update User Password ──────────────────────────────────────────
58
+ await db.user.update({
59
+ where: { id: user.id },
60
+ data: { password: hashedPassword },
61
+ });
62
+
63
+ // ── Invalidate All Sessions (force re-login) ──────────────────────
64
+ await db.session.deleteMany({
65
+ where: { userId: user.id },
66
+ });
67
+
68
+ logActivity(user.id, 'password_change', 'Password changed', 'Account password was updated');
69
+
70
+ return NextResponse.json({
71
+ success: true,
72
+ message: 'Password updated successfully',
73
+ });
74
+ } catch (error) {
75
+ console.error('[PASSWORD_CHANGE_ERROR]', error);
76
+ return NextResponse.json(
77
+ { error: 'Internal server error' },
78
+ { status: 500 }
79
+ );
80
+ }
81
+ }
src/app/api/auth/signup/route.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { db } from '@/lib/db';
3
+ import { signupSchema, getClientIp } from '@/lib/validation';
4
+ import {
5
+ hashPassword,
6
+ createSession,
7
+ generateApiKey,
8
+ SESSION_COOKIE_NAME,
9
+ } from '@/lib/auth';
10
+ import { authRateLimit } from '@/lib/rate-limit';
11
+ import { logActivity } from '@/lib/activity';
12
+
13
+ export async function POST(request: NextRequest) {
14
+ try {
15
+ // ── Rate Limit ────────────────────────────────────────────────────
16
+ const ip = getClientIp(request);
17
+ const { allowed, retryAfterMs } = await authRateLimit(`auth:${ip}`);
18
+ if (!allowed) {
19
+ return NextResponse.json(
20
+ { error: 'Too many signup attempts. Please try again later.' },
21
+ {
22
+ status: 429,
23
+ headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) },
24
+ }
25
+ );
26
+ }
27
+
28
+ // ── Parse & Validate Body ─────────────────────────────────────────
29
+ const body = await request.json();
30
+ const parsed = signupSchema.safeParse(body);
31
+ if (!parsed.success) {
32
+ return NextResponse.json(
33
+ { error: parsed.error.issues[0].message },
34
+ { status: 400 }
35
+ );
36
+ }
37
+
38
+ const { name, email, password } = parsed.data;
39
+
40
+ // ── Check if Email Exists ─────────────────────────────────────────
41
+ const existing = await db.user.findUnique({ where: { email } });
42
+ if (existing) {
43
+ return NextResponse.json(
44
+ { error: 'An account with this email already exists' },
45
+ { status: 409 }
46
+ );
47
+ }
48
+
49
+ // ── Hash Password & Generate API Key ──────────────────────────────
50
+ const [hashedPassword, apiKey] = await Promise.all([
51
+ hashPassword(password),
52
+ generateApiKey(),
53
+ ]);
54
+
55
+ // ── Create User ───────────────────────────────────────────────────
56
+ const user = await db.user.create({
57
+ data: {
58
+ name,
59
+ email,
60
+ password: hashedPassword,
61
+ apiKey,
62
+ plan: 'free',
63
+ },
64
+ });
65
+
66
+ // ── Create Session ────────────────────────────────────────────────
67
+ const token = await createSession(user.id);
68
+
69
+ // ── Set Cookie & Respond ──────────────────────────────────────────
70
+ const response = NextResponse.json({
71
+ user: {
72
+ id: user.id,
73
+ email: user.email,
74
+ name: user.name,
75
+ plan: user.plan,
76
+ createdAt: user.createdAt,
77
+ },
78
+ });
79
+
80
+ response.cookies.set(SESSION_COOKIE_NAME, token, {
81
+ httpOnly: true,
82
+ secure: true,
83
+ sameSite: 'lax',
84
+ path: '/',
85
+ maxAge: 7 * 24 * 60 * 60, // 7 days
86
+ });
87
+
88
+ logActivity(user.id, 'signup', 'Account created', user.email);
89
+
90
+ return response;
91
+ } catch (error) {
92
+ console.error('[SIGNUP_ERROR]', error);
93
+ return NextResponse.json(
94
+ { error: 'Internal server error' },
95
+ { status: 500 }
96
+ );
97
+ }
98
+ }
src/app/api/chat/route.ts ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { db } from '@/lib/db';
3
+ import { getCookieToken, getSessionUser } from '@/lib/auth';
4
+ import { chatSchema } from '@/lib/validation';
5
+ import { chatRateLimit } from '@/lib/rate-limit';
6
+
7
+ function buildInvoiceContext(userId: string): string {
8
+ // Intentionally synchronous placeholder — actual query runs async
9
+ return '';
10
+ }
11
+
12
+ export async function POST(request: NextRequest) {
13
+ try {
14
+ // ── Auth ──────────────────────────────────────────────────────────
15
+ const token = getCookieToken(request);
16
+ if (!token) {
17
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
18
+ }
19
+ const user = await getSessionUser(token);
20
+ if (!user) {
21
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
22
+ }
23
+
24
+ // ── Plan Check (Pro / Enterprise only) ───────────────────────────
25
+ if (user.plan !== 'pro' && user.plan !== 'enterprise') {
26
+ return NextResponse.json(
27
+ { error: 'AI Chat is available on Pro and Enterprise plans. Please upgrade your plan.' },
28
+ { status: 403 }
29
+ );
30
+ }
31
+
32
+ // ── Rate Limit ────────────────────────────────────────────────────
33
+ const { allowed, retryAfterMs } = await chatRateLimit(`chat:${user.id}`);
34
+ if (!allowed) {
35
+ return NextResponse.json(
36
+ { error: 'Chat rate limit exceeded. Please try again later.' },
37
+ {
38
+ status: 429,
39
+ headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) },
40
+ }
41
+ );
42
+ }
43
+
44
+ // ── Validate Body ─────────────────────────────────────────────────
45
+ const body = await request.json();
46
+ const parsed = chatSchema.safeParse(body);
47
+ if (!parsed.success) {
48
+ return NextResponse.json(
49
+ { error: parsed.error.issues[0].message },
50
+ { status: 400 }
51
+ );
52
+ }
53
+
54
+ const { message, history = [] } = parsed.data;
55
+
56
+ // ── Fetch User's Invoices for Context ─────────────────────────────
57
+ const recentInvoices = await db.invoice.findMany({
58
+ where: { userId: user.id },
59
+ orderBy: { createdAt: 'desc' },
60
+ take: 20,
61
+ });
62
+
63
+ const invoiceSummary = recentInvoices.length > 0
64
+ ? recentInvoices
65
+ .map(
66
+ (inv) =>
67
+ `- Invoice ${inv.invNumber || 'N/A'} from ${inv.vendor || 'Unknown'}: ${inv.currency} ${inv.total ?? 0} (${inv.status}, ${inv.isDuplicate ? 'duplicate' : 'unique'}, confidence: ${inv.confidence ? (inv.confidence * 100).toFixed(0) + '%' : 'N/A'})`
68
+ )
69
+ .join('\n')
70
+ : 'No invoices found.';
71
+
72
+ const totalAmount = recentInvoices.reduce((sum, inv) => sum + (inv.total ?? 0), 0);
73
+ const duplicateCount = recentInvoices.filter((inv) => inv.isDuplicate).length;
74
+
75
+ const systemPrompt = `You are OmniParse AI, an intelligent invoice processing assistant. You help users understand, analyze, and manage their invoices. Be concise, helpful, and professional.
76
+
77
+ Here is the user's recent invoice data:
78
+ Total invoices (recent 20): ${recentInvoices.length}
79
+ Total amount: USD ${totalAmount.toFixed(2)}
80
+ Duplicates found: ${duplicateCount}
81
+
82
+ ${invoiceSummary}
83
+
84
+ The user is on the "${user.plan}" plan. Answer questions about their invoices, provide spending insights, flag potential duplicates, and help with invoice management. Keep responses concise and actionable.`;
85
+
86
+ // ── Try AI SDK, Fall Back to Demo Response ────────────────────────
87
+ let aiResponse: string;
88
+
89
+ try {
90
+ // Attempt to use z-ai-web-dev-sdk for real AI responses
91
+ const { default: ZAI } = await import('z-ai-web-dev-sdk');
92
+ const zai = await ZAI.create();
93
+
94
+ const messages: Array<{ role: string; content: string }> = [
95
+ { role: 'system', content: systemPrompt },
96
+ ...history.map((h) => ({ role: h.role, content: h.content })),
97
+ { role: 'user', content: message },
98
+ ];
99
+
100
+ const result = await zai.chat.completions.create({
101
+ model: 'default',
102
+ messages: messages as never,
103
+ });
104
+
105
+ const completion = result as Record<string, unknown>;
106
+ const choices = completion.choices as Array<{ message: { content: string } }>;
107
+ aiResponse = choices?.[0]?.message?.content ?? 'I was unable to generate a response. Please try again.';
108
+ } catch {
109
+ // Fallback: Generate a helpful context-aware demo response
110
+ aiResponse = generateDemoResponse(message, recentInvoices, totalAmount, duplicateCount, user.plan);
111
+ }
112
+
113
+ return NextResponse.json({ reply: aiResponse });
114
+ } catch (error) {
115
+ console.error('[CHAT_ERROR]', error);
116
+ return NextResponse.json(
117
+ { error: 'Internal server error' },
118
+ { status: 500 }
119
+ );
120
+ }
121
+ }
122
+
123
+ // ─── Demo Response Generator (fallback when AI SDK unavailable) ────────────────
124
+
125
+ function generateDemoResponse(
126
+ message: string,
127
+ invoices: Array<{
128
+ vendor: string | null;
129
+ invNumber: string | null;
130
+ total: number | null;
131
+ currency: string;
132
+ status: string;
133
+ isDuplicate: boolean;
134
+ confidence: number | null;
135
+ invDate: string | null;
136
+ dueDate: string | null;
137
+ }>,
138
+ totalAmount: number,
139
+ duplicateCount: number,
140
+ plan: string
141
+ ): string {
142
+ const msgLower = message.toLowerCase();
143
+
144
+ // Summary / overview queries
145
+ if (msgLower.includes('summary') || msgLower.includes('overview') || msgLower.includes('how many')) {
146
+ if (invoices.length === 0) {
147
+ return "You don't have any invoices yet. Upload some invoices to get started, and I'll help you analyze them!";
148
+ }
149
+ const vendors = [...new Set(invoices.map((i) => i.vendor).filter(Boolean))];
150
+ const avgAmount = totalAmount / invoices.length;
151
+ return `Here's a quick overview of your invoices:\n\n` +
152
+ `📊 **Total invoices:** ${invoices.length}\n` +
153
+ `💰 **Total amount:** $${totalAmount.toFixed(2)}\n` +
154
+ `📈 **Average invoice:** $${avgAmount.toFixed(2)}\n` +
155
+ `🏢 **Unique vendors:** ${vendors.length} (${vendors.slice(0, 5).join(', ')}${vendors.length > 5 ? '...' : ''})\n` +
156
+ `🔄 **Duplicates:** ${duplicateCount}\n\n` +
157
+ `Would you like me to break this down by vendor or time period?`;
158
+ }
159
+
160
+ // Duplicate queries
161
+ if (msgLower.includes('duplicate') || msgLower.includes('repeat')) {
162
+ if (duplicateCount === 0) {
163
+ return "Great news! No duplicates were detected in your invoices. The system automatically flags potential duplicates during upload based on vendor and amount matching.";
164
+ }
165
+ const dups = invoices.filter((i) => i.isDuplicate);
166
+ const dupSummary = dups
167
+ .map((i) => `• ${i.invNumber || 'N/A'} from ${i.vendor || 'Unknown'} — $${i.total ?? 0}`)
168
+ .join('\n');
169
+ return `⚠️ **${duplicateCount} potential duplicate(s) found:**\n\n${dupSummary}\n\n` +
170
+ `These were flagged because they share the same vendor and a similar amount with another invoice. You can review them in the "Duplicates" tab or delete any that are truly repeated.`;
171
+ }
172
+
173
+ // Vendor queries
174
+ if (msgLower.includes('vendor') || msgLower.includes('supplier') || msgLower.includes('company')) {
175
+ const vendorMap = new Map<string, { count: number; total: number }>();
176
+ for (const inv of invoices) {
177
+ const v = inv.vendor || 'Unknown';
178
+ const existing = vendorMap.get(v) ?? { count: 0, total: 0 };
179
+ vendorMap.set(v, {
180
+ count: existing.count + 1,
181
+ total: existing.total + (inv.total ?? 0),
182
+ });
183
+ }
184
+ const lines = [...vendorMap.entries()]
185
+ .sort((a, b) => b[1].total - a[1].total)
186
+ .map(([v, data]) => `• **${v}**: ${data.count} invoice(s), totaling $${data.total.toFixed(2)}`)
187
+ .join('\n');
188
+ return `Here's a breakdown by vendor:\n\n${lines || 'No vendor data available.'}`;
189
+ }
190
+
191
+ // Spending / amount queries
192
+ if (msgLower.includes('spend') || msgLower.includes('cost') || msgLower.includes('total') || msgLower.includes('amount')) {
193
+ if (invoices.length === 0) return "No invoices to analyze yet.";
194
+ const avgAmount = totalAmount / invoices.length;
195
+ const maxInv = invoices.reduce((a, b) => (b.total ?? 0) > (a.total ?? 0) ? b : a);
196
+ const minInv = invoices.reduce((a, b) => (b.total ?? 0) < (a.total ?? 0) ? b : a);
197
+ return `💰 **Spending Analysis:**\n\n` +
198
+ `• Total: $${totalAmount.toFixed(2)} across ${invoices.length} invoices\n` +
199
+ `• Average: $${avgAmount.toFixed(2)} per invoice\n` +
200
+ `• Largest: $${maxInv.total ?? 0} from ${maxInv.vendor || 'Unknown'} (${maxInv.invNumber || 'N/A'})\n` +
201
+ `• Smallest: $${minInv.total ?? 0} from ${minInv.vendor || 'Unknown'} (${minInv.invNumber || 'N/A'})\n\n` +
202
+ `Want me to analyze spending trends or identify cost-saving opportunities?`;
203
+ }
204
+
205
+ // Default helpful response
206
+ return `I can help you with your invoices! Here are some things you can ask me:\n\n` +
207
+ `• **"Give me a summary"** — Overview of all your invoices\n` +
208
+ `• **"Show duplicates"** — Find potential duplicate invoices\n` +
209
+ `• **"Breakdown by vendor"** — Spending per vendor\n` +
210
+ `• **"Spending analysis"** — Cost insights and trends\n\n` +
211
+ `You currently have **${invoices.length} invoice(s)** totaling **$${totalAmount.toFixed(2)}** on the **${plan}** plan. How can I help?`;
212
+ }
213
+
214
+ // Suppress unused import warning
215
+ void buildInvoiceContext;
src/app/api/invoices/export/csv/route.ts ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { db } from '@/lib/db';
3
+ import { getCookieToken, getSessionUser } from '@/lib/auth';
4
+ import { logActivity } from '@/lib/activity';
5
+
6
+ function escapeCsvField(value: unknown): string {
7
+ const str = value === null || value === undefined ? '' : String(value);
8
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
9
+ return `"${str.replace(/"/g, '""')}"`;
10
+ }
11
+ return str;
12
+ }
13
+
14
+ export async function GET(request: NextRequest) {
15
+ try {
16
+ const token = getCookieToken(request);
17
+ if (!token) {
18
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
19
+ }
20
+ const user = await getSessionUser(token);
21
+ if (!user) {
22
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
23
+ }
24
+
25
+ const invoices = await db.invoice.findMany({
26
+ where: { userId: user.id },
27
+ orderBy: { createdAt: 'desc' },
28
+ });
29
+
30
+ const headers = [
31
+ 'ID',
32
+ 'Filename',
33
+ 'Vendor',
34
+ 'Invoice Number',
35
+ 'Invoice Date',
36
+ 'Due Date',
37
+ 'Subtotal',
38
+ 'VAT Amount',
39
+ 'Total',
40
+ 'Currency',
41
+ 'Status',
42
+ 'Duplicate',
43
+ 'Confidence',
44
+ 'Created At',
45
+ ];
46
+
47
+ const rows = invoices.map((inv) =>
48
+ [
49
+ inv.id,
50
+ inv.filename ?? '',
51
+ inv.vendor ?? '',
52
+ inv.invNumber ?? '',
53
+ inv.invDate ?? '',
54
+ inv.dueDate ?? '',
55
+ inv.amount ?? '',
56
+ inv.vatAmount ?? '',
57
+ inv.total ?? '',
58
+ inv.currency,
59
+ inv.status,
60
+ inv.isDuplicate ? 'Yes' : 'No',
61
+ inv.confidence !== null ? (inv.confidence * 100).toFixed(1) + '%' : '',
62
+ inv.createdAt.toISOString(),
63
+ ].map(escapeCsvField).join(',')
64
+ );
65
+
66
+ const csvContent = [
67
+ headers.map(escapeCsvField).join(','),
68
+ ...rows,
69
+ ].join('\n');
70
+
71
+ const timestamp = new Date().toISOString().split('T')[0];
72
+
73
+ logActivity(user.id, 'export', 'CSV exported', 'Exported all invoices as CSV');
74
+
75
+ return new NextResponse(csvContent, {
76
+ status: 200,
77
+ headers: {
78
+ 'Content-Type': 'text/csv; charset=utf-8',
79
+ 'Content-Disposition': `attachment; filename="invoices-${timestamp}.csv"`,
80
+ },
81
+ });
82
+ } catch (error) {
83
+ console.error('[EXPORT_CSV_ERROR]', error);
84
+ return NextResponse.json(
85
+ { error: 'Internal server error' },
86
+ { status: 500 }
87
+ );
88
+ }
89
+ }
src/app/api/invoices/export/json/route.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { db } from '@/lib/db';
3
+ import { getCookieToken, getSessionUser } from '@/lib/auth';
4
+
5
+ export async function GET(request: NextRequest) {
6
+ try {
7
+ const token = getCookieToken(request);
8
+ if (!token) {
9
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
10
+ }
11
+ const user = await getSessionUser(token);
12
+ if (!user) {
13
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
14
+ }
15
+
16
+ // Free plan users cannot export JSON
17
+ if (user.plan === 'free') {
18
+ return NextResponse.json(
19
+ { error: 'JSON export is available on Basic, Pro, and Enterprise plans. Please upgrade your plan.' },
20
+ { status: 403 }
21
+ );
22
+ }
23
+
24
+ const invoices = await db.invoice.findMany({
25
+ where: { userId: user.id },
26
+ orderBy: { createdAt: 'desc' },
27
+ });
28
+
29
+ const jsonData = invoices.map((inv) => ({
30
+ id: inv.id,
31
+ filename: inv.filename,
32
+ vendor: inv.vendor,
33
+ invoiceNumber: inv.invNumber,
34
+ invoiceDate: inv.invDate,
35
+ dueDate: inv.dueDate,
36
+ subtotal: inv.amount,
37
+ vatAmount: inv.vatAmount,
38
+ total: inv.total,
39
+ currency: inv.currency,
40
+ status: inv.status,
41
+ isDuplicate: inv.isDuplicate,
42
+ confidence: inv.confidence,
43
+ rawJson: inv.rawJson ? JSON.parse(inv.rawJson) : null,
44
+ createdAt: inv.createdAt,
45
+ updatedAt: inv.updatedAt,
46
+ }));
47
+
48
+ const timestamp = new Date().toISOString().split('T')[0];
49
+ return new NextResponse(JSON.stringify(jsonData, null, 2), {
50
+ status: 200,
51
+ headers: {
52
+ 'Content-Type': 'application/json; charset=utf-8',
53
+ 'Content-Disposition': `attachment; filename="invoices-${timestamp}.json"`,
54
+ },
55
+ });
56
+ } catch (error) {
57
+ console.error('[EXPORT_JSON_ERROR]', error);
58
+ return NextResponse.json(
59
+ { error: 'Internal server error' },
60
+ { status: 500 }
61
+ );
62
+ }
63
+ }
src/app/api/invoices/export/xlsx/route.ts ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { db } from '@/lib/db';
3
+ import { getCookieToken, getSessionUser } from '@/lib/auth';
4
+
5
+ export async function GET(request: NextRequest) {
6
+ try {
7
+ const token = getCookieToken(request);
8
+ if (!token) {
9
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
10
+ }
11
+ const user = await getSessionUser(token);
12
+ if (!user) {
13
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
14
+ }
15
+
16
+ const invoices = await db.invoice.findMany({
17
+ where: { userId: user.id },
18
+ orderBy: { createdAt: 'desc' },
19
+ });
20
+
21
+ // Dynamic import to avoid bundling issues
22
+ const ExcelJS = await import('exceljs');
23
+ const workbook = new ExcelJS.Workbook();
24
+ const worksheet = workbook.addWorksheet('Invoices');
25
+
26
+ // Style definitions
27
+ const headerFill = {
28
+ type: 'pattern' as const,
29
+ pattern: 'solid' as const,
30
+ fgColor: { argb: 'FFF59E0B' },
31
+ };
32
+ const headerFont = { bold: true, color: { argb: 'FF09090B' }, size: 11 };
33
+ const borderStyle = {
34
+ bottom: { style: 'thin' as const, color: { argb: 'FFD4D4D8' } },
35
+ };
36
+
37
+ // Title row
38
+ worksheet.mergeCells('A1:K1');
39
+ const titleCell = worksheet.getCell('A1');
40
+ titleCell.value = 'OmniParse AI \u2014 Invoice Export';
41
+ titleCell.font = { bold: true, size: 14, color: { argb: 'FF18181B' } };
42
+ titleCell.alignment = { horizontal: 'center' };
43
+
44
+ // Date row
45
+ worksheet.mergeCells('A2:K2');
46
+ const dateCell = worksheet.getCell('A2');
47
+ dateCell.value = `Exported: ${new Date().toLocaleString('en-US')}`;
48
+ dateCell.font = { italic: true, size: 10, color: { argb: 'FF71717A' } };
49
+ dateCell.alignment = { horizontal: 'center' };
50
+
51
+ // Headers (row 4)
52
+ const headers = ['Vendor', 'Invoice #', 'Invoice Date', 'Due Date', 'Amount', 'VAT', 'Total', 'Currency', 'Status', 'Duplicate', 'Confidence', 'Filename'];
53
+ headers.forEach((h, i) => {
54
+ const cell = worksheet.getCell(4, i + 1);
55
+ cell.value = h;
56
+ cell.fill = headerFill;
57
+ cell.font = headerFont;
58
+ cell.alignment = { horizontal: 'center' };
59
+ cell.border = borderStyle;
60
+ });
61
+
62
+ // Data rows
63
+ invoices.forEach((inv, idx) => {
64
+ const row = idx + 5;
65
+ worksheet.getCell(row, 1).value = inv.vendor || '';
66
+ worksheet.getCell(row, 2).value = inv.invNumber || '';
67
+ worksheet.getCell(row, 3).value = inv.invDate || '';
68
+ worksheet.getCell(row, 4).value = inv.dueDate || '';
69
+ worksheet.getCell(row, 5).value = inv.amount ?? 0;
70
+ worksheet.getCell(row, 6).value = inv.vatAmount ?? 0;
71
+ worksheet.getCell(row, 7).value = inv.total ?? 0;
72
+ worksheet.getCell(row, 8).value = inv.currency;
73
+ worksheet.getCell(row, 9).value = inv.isDuplicate ? 'Yes' : 'No';
74
+ worksheet.getCell(row, 10).value = inv.confidence ? `${(inv.confidence * 100).toFixed(1)}%` : '';
75
+ worksheet.getCell(row, 11).value = inv.filename || '';
76
+
77
+ // Format currency columns
78
+ [5, 6, 7].forEach(col => {
79
+ worksheet.getCell(row, col).numFmt = '$#,##0.00';
80
+ });
81
+
82
+ // Alternate row shading
83
+ if (idx % 2 === 0) {
84
+ for (let col = 1; col <= 12; col++) {
85
+ worksheet.getCell(row, col).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF4F4F5' } };
86
+ }
87
+ }
88
+
89
+ // Color status
90
+ const statusCell = worksheet.getCell(row, 9);
91
+ if (inv.isDuplicate) {
92
+ statusCell.font = { color: { argb: 'FFEF4444' } };
93
+ } else if (inv.status === 'review') {
94
+ statusCell.font = { color: { argb: 'FFEAB308' } };
95
+ } else {
96
+ statusCell.font = { color: { argb: 'FF22C55E' } };
97
+ }
98
+
99
+ // Bottom border
100
+ for (let col = 1; col <= 12; col++) {
101
+ worksheet.getCell(row, col).border = borderStyle;
102
+ }
103
+ });
104
+
105
+ // Summary row
106
+ const summaryRow = invoices.length + 5;
107
+ worksheet.getCell(summaryRow, 6).value = 'TOTAL';
108
+ worksheet.getCell(summaryRow, 6).font = { bold: true };
109
+ const totalAmount = invoices.reduce((s, i) => s + (i.total ?? 0), 0);
110
+ worksheet.getCell(summaryRow, 7).value = totalAmount;
111
+ worksheet.getCell(summaryRow, 7).numFmt = '$#,##0.00';
112
+ worksheet.getCell(summaryRow, 7).font = { bold: true, size: 12 };
113
+ for (let col = 1; col <= 12; col++) {
114
+ worksheet.getCell(summaryRow, col).border = { top: { style: 'double', color: { argb: 'FF18181B' } } };
115
+ }
116
+
117
+ // Auto-fit column widths
118
+ worksheet.columns.forEach((column, i) => {
119
+ const maxLength = Math.max(
120
+ headers[i]?.length || 5,
121
+ ...invoices.slice(0, 20).map(inv => {
122
+ const vals = [inv.vendor, inv.invNumber, inv.invDate, inv.dueDate, inv.currency, inv.filename];
123
+ return (vals[i] || '').toString().length;
124
+ })
125
+ );
126
+ column.width = Math.min(maxLength + 4, 30);
127
+ });
128
+
129
+ const buffer = await workbook.xlsx.writeBuffer();
130
+ return new NextResponse(buffer, {
131
+ status: 200,
132
+ headers: {
133
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
134
+ 'Content-Disposition': `attachment; filename="omniparse-invoices-${new Date().toISOString().split('T')[0]}.xlsx"`,
135
+ },
136
+ });
137
+ } catch (error) {
138
+ console.error('[EXPORT_XLSX_ERROR]', error);
139
+ return NextResponse.json(
140
+ { error: 'Internal server error' },
141
+ { status: 500 }
142
+ );
143
+ }
144
+ }
src/app/api/invoices/route.ts ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { db } from '@/lib/db';
3
+ import { getCookieToken, getSessionUser } from '@/lib/auth';
4
+ import { invoiceIdSchema } from '@/lib/validation';
5
+ import { logActivity } from '@/lib/activity';
6
+
7
+ type FilterValue = 'all' | 'done' | 'review' | 'duplicates';
8
+
9
+ // ─── GET: List Invoices ───────────────────────────────────────────────────────
10
+
11
+ export async function GET(request: NextRequest) {
12
+ try {
13
+ const token = getCookieToken(request);
14
+ if (!token) {
15
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
16
+ }
17
+ const user = await getSessionUser(token);
18
+ if (!user) {
19
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
20
+ }
21
+
22
+ const { searchParams } = new URL(request.url);
23
+ const filter = (searchParams.get('filter') || 'all') as FilterValue;
24
+
25
+ // Build the where clause
26
+ const where: Record<string, unknown> = { userId: user.id };
27
+
28
+ switch (filter) {
29
+ case 'done':
30
+ where.status = 'done';
31
+ where.isDuplicate = false;
32
+ break;
33
+ case 'review':
34
+ where.status = 'review';
35
+ break;
36
+ case 'duplicates':
37
+ where.isDuplicate = true;
38
+ break;
39
+ // 'all' uses the base where (userId only)
40
+ }
41
+
42
+ const invoices = await db.invoice.findMany({
43
+ where,
44
+ orderBy: { createdAt: 'desc' },
45
+ });
46
+
47
+ return NextResponse.json({ invoices });
48
+ } catch (error) {
49
+ console.error('[INVOICES_GET_ERROR]', error);
50
+ return NextResponse.json(
51
+ { error: 'Internal server error' },
52
+ { status: 500 }
53
+ );
54
+ }
55
+ }
56
+
57
+ // ─── DELETE: Remove Invoice ────────────────────────────────────────────────────
58
+
59
+ export async function DELETE(request: NextRequest) {
60
+ try {
61
+ const token = getCookieToken(request);
62
+ if (!token) {
63
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
64
+ }
65
+ const user = await getSessionUser(token);
66
+ if (!user) {
67
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
68
+ }
69
+
70
+ const body = await request.json();
71
+ const parsed = invoiceIdSchema.safeParse(body);
72
+ if (!parsed.success) {
73
+ return NextResponse.json(
74
+ { error: parsed.error.issues[0].message },
75
+ { status: 400 }
76
+ );
77
+ }
78
+
79
+ // Verify ownership
80
+ const invoice = await db.invoice.findUnique({
81
+ where: { id: parsed.data.id },
82
+ });
83
+
84
+ if (!invoice || invoice.userId !== user.id) {
85
+ return NextResponse.json(
86
+ { error: 'Invoice not found' },
87
+ { status: 404 }
88
+ );
89
+ }
90
+
91
+ await db.invoice.delete({ where: { id: parsed.data.id } });
92
+
93
+ logActivity(user.id, 'delete', 'Invoice deleted', `Removed invoice ${parsed.data.id}`);
94
+
95
+ return NextResponse.json({ success: true });
96
+ } catch (error) {
97
+ console.error('[INVOICES_DELETE_ERROR]', error);
98
+ return NextResponse.json(
99
+ { error: 'Internal server error' },
100
+ { status: 500 }
101
+ );
102
+ }
103
+ }
src/app/api/invoices/update/route.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { db } from '@/lib/db';
3
+ import { getSessionUser } from '@/lib/auth';
4
+ import { z } from 'zod';
5
+ import { logActivity } from '@/lib/activity';
6
+
7
+ const updateSchema = z.object({
8
+ id: z.string().min(1),
9
+ vendor: z.string().optional(),
10
+ invNumber: z.string().optional(),
11
+ invDate: z.string().optional(),
12
+ dueDate: z.string().optional(),
13
+ amount: z.string().transform(Number).optional(),
14
+ vatAmount: z.string().transform(Number).optional(),
15
+ total: z.string().transform(Number).optional(),
16
+ currency: z.string().optional(),
17
+ status: z.enum(['done', 'review']).optional(),
18
+ });
19
+
20
+ export async function POST(req: NextRequest) {
21
+ try {
22
+ const user = await getSessionUser(req);
23
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
24
+
25
+ const body = await req.json();
26
+ const parsed = updateSchema.safeParse(body);
27
+ if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
28
+
29
+ const { id, ...data } = parsed.data;
30
+ const invoice = await db.invoice.findFirst({ where: { id, userId: user.id } });
31
+ if (!invoice) return NextResponse.json({ error: 'Not found' }, { status: 404 });
32
+
33
+ const updated = await db.invoice.update({
34
+ where: { id },
35
+ data,
36
+ });
37
+
38
+ logActivity(user.id, 'edit', 'Invoice updated', 'Updated invoice fields');
39
+
40
+ return NextResponse.json({ invoice: updated });
41
+ } catch (error) {
42
+ console.error('Invoice update error:', error);
43
+ return NextResponse.json({ error: 'Internal error' }, { status: 500 });
44
+ }
45
+ }
src/app/api/route.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+
3
+ export async function GET() {
4
+ return NextResponse.json({ message: "Hello, world!" });
5
+ }
src/app/api/seed/route.ts ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { db } from '@/lib/db';
2
+ import { hashPassword, generateApiKey } from '@/lib/auth';
3
+ import { NextResponse } from 'next/server';
4
+
5
+ export async function GET() {
6
+ try {
7
+ // Upsert demo user
8
+ const hashedPw = await hashPassword('demo1234');
9
+ const apiKey = generateApiKey();
10
+
11
+ const user = await db.user.upsert({
12
+ where: { email: 'demo@omniparse.ai' },
13
+ update: {},
14
+ create: {
15
+ email: 'demo@omniparse.ai',
16
+ name: 'Demo User',
17
+ password: hashedPw,
18
+ plan: 'pro',
19
+ apiKey,
20
+ },
21
+ });
22
+
23
+ // Delete any existing invoices for this user
24
+ await db.invoice.deleteMany({
25
+ where: { userId: user.id },
26
+ });
27
+
28
+ // Define 20 diverse invoices
29
+ const invoiceData = [
30
+ // ── January 2025 (5 invoices) ──
31
+ {
32
+ vendor: 'Acme Corp',
33
+ invNumber: 'INV-2024-001',
34
+ invDate: '2025-01-05',
35
+ dueDate: '2025-02-04',
36
+ amount: 1200,
37
+ vatPct: 0.20,
38
+ currency: 'USD',
39
+ status: 'done' as const,
40
+ isDuplicate: false,
41
+ confidence: 0.95,
42
+ filename: 'invoice_acme_jan.pdf',
43
+ createdAt: new Date('2025-01-06T09:15:00Z'),
44
+ },
45
+ {
46
+ vendor: 'TechVision Inc',
47
+ invNumber: 'INV-2024-002',
48
+ invDate: '2025-01-12',
49
+ dueDate: '2025-02-11',
50
+ amount: 3450,
51
+ vatPct: 0.21,
52
+ currency: 'EUR',
53
+ status: 'done' as const,
54
+ isDuplicate: false,
55
+ confidence: 0.97,
56
+ filename: 'invoice_techvision_jan.pdf',
57
+ createdAt: new Date('2025-01-13T14:30:00Z'),
58
+ },
59
+ {
60
+ vendor: 'Global Supplies Ltd',
61
+ invNumber: 'INV-2024-003',
62
+ invDate: '2025-01-18',
63
+ dueDate: '2025-02-17',
64
+ amount: 890,
65
+ vatPct: 0.15,
66
+ currency: 'USD',
67
+ status: 'done' as const,
68
+ isDuplicate: false,
69
+ confidence: 0.92,
70
+ filename: 'receipt_global_jan.png',
71
+ createdAt: new Date('2025-01-19T11:00:00Z'),
72
+ },
73
+ {
74
+ vendor: 'CloudNine Services',
75
+ invNumber: 'INV-2024-004',
76
+ invDate: '2025-01-22',
77
+ dueDate: '2025-02-21',
78
+ amount: 5200,
79
+ vatPct: 0.20,
80
+ currency: 'USD',
81
+ status: 'review' as const,
82
+ isDuplicate: false,
83
+ confidence: 0.88,
84
+ filename: 'invoice_cloudnine_jan.pdf',
85
+ createdAt: new Date('2025-01-23T08:45:00Z'),
86
+ },
87
+ {
88
+ vendor: 'DataFlow Systems',
89
+ invNumber: 'INV-2024-005',
90
+ invDate: '2025-01-28',
91
+ dueDate: '2025-02-27',
92
+ amount: 750,
93
+ vatPct: 0.10,
94
+ currency: 'USD',
95
+ status: 'done' as const,
96
+ isDuplicate: false,
97
+ confidence: 0.96,
98
+ filename: 'receipt_dataflow_jan.pdf',
99
+ createdAt: new Date('2025-01-29T16:20:00Z'),
100
+ },
101
+
102
+ // ── February 2025 (5 invoices) ──
103
+ {
104
+ vendor: 'Pinnacle Solutions',
105
+ invNumber: 'INV-2024-006',
106
+ invDate: '2025-02-03',
107
+ dueDate: '2025-03-05',
108
+ amount: 1250,
109
+ vatPct: 0.18,
110
+ currency: 'USD',
111
+ status: 'done' as const,
112
+ isDuplicate: false,
113
+ confidence: 0.94,
114
+ filename: 'invoice_pinnacle_feb.pdf',
115
+ createdAt: new Date('2025-02-04T10:00:00Z'),
116
+ },
117
+ {
118
+ vendor: 'TechVision Inc',
119
+ invNumber: 'INV-2024-007',
120
+ invDate: '2025-02-08',
121
+ dueDate: '2025-03-10',
122
+ amount: 2100,
123
+ vatPct: 0.20,
124
+ currency: 'USD',
125
+ status: 'done' as const,
126
+ isDuplicate: false,
127
+ confidence: 0.98,
128
+ filename: 'invoice_techvision_feb.pdf',
129
+ createdAt: new Date('2025-02-09T13:15:00Z'),
130
+ },
131
+ {
132
+ vendor: 'Acme Corp',
133
+ invNumber: 'INV-2024-008',
134
+ invDate: '2025-02-14',
135
+ dueDate: '2025-03-16',
136
+ amount: 8750,
137
+ vatPct: 0.20,
138
+ currency: 'GBP',
139
+ status: 'review' as const,
140
+ isDuplicate: false,
141
+ confidence: 0.87,
142
+ filename: 'invoice_acme_feb.pdf',
143
+ createdAt: new Date('2025-02-15T09:30:00Z'),
144
+ },
145
+ {
146
+ vendor: 'Meridian Group',
147
+ invNumber: 'INV-2024-009',
148
+ invDate: '2025-02-20',
149
+ dueDate: '2025-03-22',
150
+ amount: 430,
151
+ vatPct: 0.12,
152
+ currency: 'USD',
153
+ status: 'pending' as const,
154
+ isDuplicate: false,
155
+ confidence: 0.99,
156
+ filename: 'receipt_meridian_feb.png',
157
+ createdAt: new Date('2025-02-21T15:45:00Z'),
158
+ },
159
+ {
160
+ vendor: 'Nexus Technologies',
161
+ invNumber: 'INV-2024-010',
162
+ invDate: '2025-02-26',
163
+ dueDate: '2025-03-28',
164
+ amount: 6300,
165
+ vatPct: 0.25,
166
+ currency: 'USD',
167
+ status: 'done' as const,
168
+ isDuplicate: false,
169
+ confidence: 0.91,
170
+ filename: 'invoice_nexus_feb.pdf',
171
+ createdAt: new Date('2025-02-27T11:10:00Z'),
172
+ },
173
+
174
+ // ── March 2025 (5 invoices) ──
175
+ {
176
+ vendor: 'CloudNine Services',
177
+ invNumber: 'INV-2024-011',
178
+ invDate: '2025-03-04',
179
+ dueDate: '2025-04-03',
180
+ amount: 4800,
181
+ vatPct: 0.20,
182
+ currency: 'USD',
183
+ status: 'done' as const,
184
+ isDuplicate: false,
185
+ confidence: 0.93,
186
+ filename: 'invoice_cloudnine_mar.pdf',
187
+ createdAt: new Date('2025-03-05T08:00:00Z'),
188
+ },
189
+ {
190
+ vendor: 'Pinnacle Solutions',
191
+ invNumber: 'INV-2024-012',
192
+ invDate: '2025-03-10',
193
+ dueDate: '2025-04-09',
194
+ amount: 15000,
195
+ vatPct: 0.22,
196
+ currency: 'USD',
197
+ status: 'review' as const,
198
+ isDuplicate: false,
199
+ confidence: 0.86,
200
+ filename: 'invoice_pinnacle_mar.pdf',
201
+ createdAt: new Date('2025-03-11T14:25:00Z'),
202
+ },
203
+ {
204
+ vendor: 'Global Supplies Ltd',
205
+ invNumber: 'INV-2024-013',
206
+ invDate: '2025-03-15',
207
+ dueDate: '2025-04-14',
208
+ amount: 2350,
209
+ vatPct: 0.19,
210
+ currency: 'EUR',
211
+ status: 'done' as const,
212
+ isDuplicate: false,
213
+ confidence: 0.95,
214
+ filename: 'invoice_global_mar.pdf',
215
+ createdAt: new Date('2025-03-16T10:40:00Z'),
216
+ },
217
+ {
218
+ vendor: 'DataFlow Systems',
219
+ invNumber: 'INV-2024-014',
220
+ invDate: '2025-03-19',
221
+ dueDate: '2025-04-18',
222
+ amount: 1100,
223
+ vatPct: 0.15,
224
+ currency: 'USD',
225
+ status: 'done' as const,
226
+ isDuplicate: false,
227
+ confidence: 0.97,
228
+ filename: 'receipt_dataflow_mar.png',
229
+ createdAt: new Date('2025-03-20T09:55:00Z'),
230
+ },
231
+ {
232
+ vendor: 'Acme Corp',
233
+ invNumber: 'INV-2024-015',
234
+ invDate: '2025-03-25',
235
+ dueDate: '2025-04-24',
236
+ amount: 9200,
237
+ vatPct: 0.20,
238
+ currency: 'USD',
239
+ status: 'done' as const,
240
+ isDuplicate: false,
241
+ confidence: 0.89,
242
+ filename: 'invoice_acme_mar.pdf',
243
+ createdAt: new Date('2025-03-26T16:30:00Z'),
244
+ },
245
+
246
+ // ── April 2025 (5 invoices) ──
247
+ {
248
+ vendor: 'Nexus Technologies',
249
+ invNumber: 'INV-2024-016',
250
+ invDate: '2025-04-02',
251
+ dueDate: '2025-05-02',
252
+ amount: 6280,
253
+ vatPct: 0.23,
254
+ currency: 'USD',
255
+ status: 'done' as const,
256
+ isDuplicate: true, // Duplicate of INV-2024-010 (same vendor, $6300)
257
+ confidence: 0.88,
258
+ filename: 'invoice_nexus_apr.pdf',
259
+ createdAt: new Date('2025-04-03T11:20:00Z'),
260
+ },
261
+ {
262
+ vendor: 'Meridian Group',
263
+ invNumber: 'INV-2024-017',
264
+ invDate: '2025-04-08',
265
+ dueDate: '2025-05-08',
266
+ amount: 780,
267
+ vatPct: 0.14,
268
+ currency: 'EUR',
269
+ status: 'pending' as const,
270
+ isDuplicate: false,
271
+ confidence: 0.96,
272
+ filename: 'receipt_meridian_apr.png',
273
+ createdAt: new Date('2025-04-09T08:10:00Z'),
274
+ },
275
+ {
276
+ vendor: 'CloudNine Services',
277
+ invNumber: 'INV-2024-018',
278
+ invDate: '2025-04-14',
279
+ dueDate: '2025-05-14',
280
+ amount: 5150,
281
+ vatPct: 0.20,
282
+ currency: 'USD',
283
+ status: 'done' as const,
284
+ isDuplicate: true, // Duplicate of INV-2024-004 (same vendor, $5200)
285
+ confidence: 0.92,
286
+ filename: 'invoice_cloudnine_apr.pdf',
287
+ createdAt: new Date('2025-04-15T13:50:00Z'),
288
+ },
289
+ {
290
+ vendor: 'Pinnacle Solutions',
291
+ invNumber: 'INV-2024-019',
292
+ invDate: '2025-04-20',
293
+ dueDate: '2025-05-20',
294
+ amount: 3200,
295
+ vatPct: 0.20,
296
+ currency: 'GBP',
297
+ status: 'done' as const,
298
+ isDuplicate: false,
299
+ confidence: 0.94,
300
+ filename: 'invoice_pinnacle_apr.pdf',
301
+ createdAt: new Date('2025-04-21T10:05:00Z'),
302
+ },
303
+ {
304
+ vendor: 'TechVision Inc',
305
+ invNumber: 'INV-2024-020',
306
+ invDate: '2025-04-27',
307
+ dueDate: '2025-05-27',
308
+ amount: 2800,
309
+ vatPct: 0.17,
310
+ currency: 'USD',
311
+ status: 'review' as const,
312
+ isDuplicate: false,
313
+ confidence: 0.85,
314
+ filename: 'invoice_techvision_apr.pdf',
315
+ createdAt: new Date('2025-04-28T15:00:00Z'),
316
+ },
317
+ ];
318
+
319
+ // Create all invoices with computed VAT and total
320
+ const invoices = await db.invoice.createMany({
321
+ data: invoiceData.map((inv) => ({
322
+ userId: user.id,
323
+ vendor: inv.vendor,
324
+ invNumber: inv.invNumber,
325
+ invDate: inv.invDate,
326
+ dueDate: inv.dueDate,
327
+ amount: inv.amount,
328
+ vatAmount: Math.round(inv.amount * inv.vatPct * 100) / 100,
329
+ total: Math.round(inv.amount * (1 + inv.vatPct) * 100) / 100,
330
+ currency: inv.currency,
331
+ status: inv.status,
332
+ isDuplicate: inv.isDuplicate,
333
+ confidence: inv.confidence,
334
+ filename: inv.filename,
335
+ rawJson: null,
336
+ createdAt: inv.createdAt,
337
+ })),
338
+ });
339
+
340
+ return NextResponse.json({
341
+ message: `Seed complete: ${invoices.count} invoices created for demo user`,
342
+ userId: user.id,
343
+ invoiceCount: invoices.count,
344
+ breakdown: {
345
+ byVendor: {
346
+ 'Acme Corp': 3,
347
+ 'TechVision Inc': 3,
348
+ 'Global Supplies Ltd': 2,
349
+ 'CloudNine Services': 3,
350
+ 'DataFlow Systems': 2,
351
+ 'Pinnacle Solutions': 3,
352
+ 'Meridian Group': 2,
353
+ 'Nexus Technologies': 2,
354
+ },
355
+ byStatus: { done: 14, review: 4, pending: 2 },
356
+ duplicates: 2,
357
+ byCurrency: { USD: 15, EUR: 3, GBP: 2 },
358
+ },
359
+ });
360
+ } catch (error) {
361
+ console.error('[SEED_ERROR]', error);
362
+ return NextResponse.json({ error: 'Seed failed' }, { status: 500 });
363
+ }
364
+ }
src/app/globals.css ADDED
@@ -0,0 +1,2040 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+
4
+ @custom-variant dark (&:is(.dark *));
5
+
6
+ @theme inline {
7
+ --color-background: var(--background);
8
+ --color-foreground: var(--foreground);
9
+ --font-sans: var(--font-geist-sans);
10
+ --font-mono: var(--font-geist-mono);
11
+ --color-ring: var(--ring);
12
+ --color-input: var(--input);
13
+ --color-border: var(--border);
14
+ --color-destructive: var(--destructive);
15
+ --color-accent-foreground: var(--accent-foreground);
16
+ --color-accent: var(--accent);
17
+ --color-muted-foreground: var(--muted-foreground);
18
+ --color-muted: var(--muted);
19
+ --color-secondary-foreground: var(--secondary-foreground);
20
+ --color-secondary: var(--secondary);
21
+ --color-primary-foreground: var(--primary-foreground);
22
+ --color-primary: var(--primary);
23
+ --color-popover-foreground: var(--popover-foreground);
24
+ --color-popover: var(--popover);
25
+ --color-card-foreground: var(--card-foreground);
26
+ --color-card: var(--card);
27
+ --radius-sm: calc(var(--radius) - 4px);
28
+ --radius-md: calc(var(--radius) - 2px);
29
+ --radius-lg: var(--radius);
30
+ --radius-xl: calc(var(--radius) + 4px);
31
+ --color-amber-50: #FFFBEB;
32
+ --color-amber-100: #FEF3C7;
33
+ --color-amber-200: #FDE68A;
34
+ --color-amber-300: #FCD34D;
35
+ --color-amber-400: #FBBF24;
36
+ --color-amber-500: #F59E0B;
37
+ --color-amber-600: #D97706;
38
+ --color-amber-700: #B45309;
39
+ --color-amber-800: #92400E;
40
+ --color-amber-900: #78350F;
41
+ --color-teal-400: #2DD4BF;
42
+ --color-teal-500: #14B8A6;
43
+ --color-teal-600: #0D9488;
44
+ }
45
+
46
+ :root {
47
+ --radius: 0.625rem;
48
+ --background: #09090B;
49
+ --foreground: #FAFAFA;
50
+ --card: #111113;
51
+ --card-foreground: #FAFAFA;
52
+ --popover: #111113;
53
+ --popover-foreground: #FAFAFA;
54
+ --primary: #F59E0B;
55
+ --primary-foreground: #09090B;
56
+ --secondary: #1C1C1F;
57
+ --secondary-foreground: #FAFAFA;
58
+ --muted: #1C1C1F;
59
+ --muted-foreground: #A1A1AA;
60
+ --accent: #1C1C1F;
61
+ --accent-foreground: #FAFAFA;
62
+ --destructive: #EF4444;
63
+ --border: rgba(255, 255, 255, 0.08);
64
+ --input: rgba(255, 255, 255, 0.1);
65
+ --ring: #F59E0B;
66
+ }
67
+
68
+ @layer base {
69
+ * {
70
+ @apply border-border outline-ring/50;
71
+ }
72
+ body {
73
+ @apply bg-background text-foreground;
74
+ font-feature-settings: "rlig" 1, "calt" 1;
75
+ }
76
+ }
77
+
78
+ /* Custom scrollbar */
79
+ ::-webkit-scrollbar {
80
+ width: 6px;
81
+ height: 6px;
82
+ }
83
+ ::-webkit-scrollbar-track {
84
+ background: transparent;
85
+ }
86
+ ::-webkit-scrollbar-thumb {
87
+ background: rgba(255, 255, 255, 0.1);
88
+ border-radius: 3px;
89
+ }
90
+ ::-webkit-scrollbar-thumb:hover {
91
+ background: rgba(255, 255, 255, 0.2);
92
+ }
93
+
94
+ /* Glassmorphism card */
95
+ .glass-card {
96
+ background: rgba(255, 255, 255, 0.03);
97
+ backdrop-filter: blur(12px);
98
+ border: 1px solid rgba(255, 255, 255, 0.06);
99
+ border-radius: 16px;
100
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
101
+ }
102
+
103
+ .glass-card-hover {
104
+ background: rgba(255, 255, 255, 0.03);
105
+ backdrop-filter: blur(12px);
106
+ border: 1px solid rgba(255, 255, 255, 0.06);
107
+ border-radius: 16px;
108
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
109
+ }
110
+ .glass-card-hover:hover {
111
+ background: rgba(255, 255, 255, 0.06);
112
+ border-color: rgba(245, 158, 11, 0.2);
113
+ transform: translateY(-2px);
114
+ box-shadow: 0 8px 32px rgba(245, 158, 11, 0.08);
115
+ }
116
+
117
+ /* Amber glow effect */
118
+ .amber-glow {
119
+ box-shadow: 0 0 40px rgba(245, 158, 11, 0.15), 0 0 80px rgba(245, 158, 11, 0.05);
120
+ }
121
+
122
+ /* Subtle grid background */
123
+ .grid-bg {
124
+ background-image:
125
+ linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
126
+ linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
127
+ background-size: 64px 64px;
128
+ }
129
+
130
+ /* Noise texture overlay */
131
+ .noise-overlay::before {
132
+ content: "";
133
+ position: fixed;
134
+ top: 0;
135
+ left: 0;
136
+ width: 100%;
137
+ height: 100%;
138
+ opacity: 0.03;
139
+ z-index: 0;
140
+ pointer-events: none;
141
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
142
+ }
143
+
144
+ /* Upload drop zone */
145
+ .drop-zone {
146
+ border: 2px dashed rgba(245, 158, 11, 0.3);
147
+ border-radius: 16px;
148
+ transition: all 0.3s ease;
149
+ }
150
+ .drop-zone.drag-over {
151
+ border-color: #F59E0B;
152
+ background: rgba(245, 158, 11, 0.05);
153
+ box-shadow: 0 0 32px rgba(245, 158, 11, 0.1);
154
+ }
155
+
156
+ /* Chat bubble animation */
157
+ @keyframes slideUp {
158
+ from { opacity: 0; transform: translateY(8px); }
159
+ to { opacity: 1; transform: translateY(0); }
160
+ }
161
+ .chat-bubble {
162
+ animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
163
+ }
164
+
165
+ /* Pricing card highlight */
166
+ .pricing-highlight {
167
+ border: 1px solid rgba(245, 158, 11, 0.4);
168
+ background: linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(20, 184, 166, 0.04) 100%);
169
+ position: relative;
170
+ }
171
+ .pricing-highlight::before {
172
+ content: "";
173
+ position: absolute;
174
+ inset: -1px;
175
+ border-radius: 16px;
176
+ padding: 1px;
177
+ background: linear-gradient(135deg, #F59E0B, #14B8A6);
178
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
179
+ -webkit-mask-composite: xor;
180
+ mask-composite: exclude;
181
+ pointer-events: none;
182
+ }
183
+
184
+ /* Fade in animation */
185
+ @keyframes fadeIn {
186
+ from { opacity: 0; }
187
+ to { opacity: 1; }
188
+ }
189
+ .fade-in {
190
+ animation: fadeIn 0.5s ease;
191
+ }
192
+
193
+ /* Status badge styles */
194
+ .status-done { background: rgba(34, 197, 94, 0.12); color: #22C55E; }
195
+ .status-review { background: rgba(234, 179, 8, 0.12); color: #EAB308; }
196
+ .status-duplicate { background: rgba(239, 68, 68, 0.12); color: #EF4444; }
197
+
198
+ /* Tab active indicator */
199
+ .tab-active {
200
+ color: #F59E0B;
201
+ border-bottom: 2px solid #F59E0B;
202
+ }
203
+
204
+ /* Focus ring for accessibility */
205
+ *:focus-visible {
206
+ outline: 2px solid #F59E0B;
207
+ outline-offset: 2px;
208
+ border-radius: 4px;
209
+ }
210
+
211
+ /* Hero gradient text */
212
+ .gradient-text {
213
+ background: linear-gradient(135deg, #F59E0B 0%, #FBBF24 40%, #14B8A6 100%);
214
+ -webkit-background-clip: text;
215
+ -webkit-text-fill-color: transparent;
216
+ background-clip: text;
217
+ }
218
+
219
+ /* Progress bar */
220
+ .progress-bar {
221
+ background: linear-gradient(90deg, #F59E0B, #14B8A6);
222
+ border-radius: 4px;
223
+ transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
224
+ }
225
+
226
+ /* Skeleton shimmer */
227
+ @keyframes shimmer {
228
+ 0% { background-position: -200% 0; }
229
+ 100% { background-position: 200% 0; }
230
+ }
231
+ .skeleton-shimmer {
232
+ background: linear-gradient(90deg, rgba(255,255,255,0.03) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.03) 75%);
233
+ background-size: 200% 100%;
234
+ animation: shimmer 1.5s infinite;
235
+ border-radius: 8px;
236
+ }
237
+
238
+ /* Hero gradient orb */
239
+ .hero-orb {
240
+ position: absolute;
241
+ width: 600px;
242
+ height: 600px;
243
+ border-radius: 50%;
244
+ background: radial-gradient(circle, rgba(245,158,11,0.12) 0%, rgba(20,184,166,0.06) 40%, transparent 70%);
245
+ filter: blur(60px);
246
+ top: -100px;
247
+ right: -150px;
248
+ pointer-events: none;
249
+ animation: orbFloat 8s ease-in-out infinite;
250
+ }
251
+ .hero-orb-2 {
252
+ position: absolute;
253
+ width: 400px;
254
+ height: 400px;
255
+ border-radius: 50%;
256
+ background: radial-gradient(circle, rgba(20,184,166,0.1) 0%, rgba(245,158,11,0.05) 40%, transparent 70%);
257
+ filter: blur(50px);
258
+ bottom: -80px;
259
+ left: -100px;
260
+ pointer-events: none;
261
+ animation: orbFloat 10s ease-in-out infinite reverse;
262
+ }
263
+ @keyframes orbFloat {
264
+ 0%, 100% { transform: translate(0, 0) scale(1); }
265
+ 33% { transform: translate(30px, -20px) scale(1.05); }
266
+ 66% { transform: translate(-20px, 15px) scale(0.95); }
267
+ }
268
+
269
+ /* Stats counter animation */
270
+ @keyframes countUp {
271
+ from { opacity: 0; transform: translateY(10px); }
272
+ to { opacity: 1; transform: translateY(0); }
273
+ }
274
+ .stat-item {
275
+ animation: countUp 0.6s ease-out both;
276
+ }
277
+ .stat-item:nth-child(2) { animation-delay: 0.1s; }
278
+ .stat-item:nth-child(3) { animation-delay: 0.2s; }
279
+
280
+ /* Feature card icon colors */
281
+ .feature-amber .feature-icon { background: rgba(245,158,11,0.1); color: #F59E0B; }
282
+ .feature-teal .feature-icon { background: rgba(20,184,166,0.1); color: #14B8A6; }
283
+ .feature-red .feature-icon { background: rgba(239,68,68,0.1); color: #EF4444; }
284
+ .feature-green .feature-icon { background: rgba(34,197,94,0.1); color: #22C55E; }
285
+ .feature-sky .feature-icon { background: rgba(56,189,248,0.1); color: #38BDF8; }
286
+ .feature-rose .feature-icon { background: rgba(244,114,182,0.1); color: #F472B6; }
287
+
288
+ /* Dashboard stat card */
289
+ .stat-card {
290
+ background: rgba(255,255,255,0.03);
291
+ border: 1px solid rgba(255,255,255,0.06);
292
+ border-radius: 12px;
293
+ padding: 16px 20px;
294
+ transition: all 0.3s ease;
295
+ }
296
+ .stat-card:hover {
297
+ background: rgba(255,255,255,0.05);
298
+ border-color: rgba(255,255,255,0.1);
299
+ transform: translateY(-1px);
300
+ }
301
+
302
+ /* Invoice row hover */
303
+ .invoice-row {
304
+ cursor: pointer;
305
+ transition: background 0.15s ease;
306
+ }
307
+ .invoice-row:hover {
308
+ background: rgba(255,255,255,0.03) !important;
309
+ }
310
+
311
+ /* Section reveal animation */
312
+ @keyframes sectionReveal {
313
+ from { opacity: 0; transform: translateY(24px); }
314
+ to { opacity: 1; transform: translateY(0); }
315
+ }
316
+ .section-reveal {
317
+ animation: sectionReveal 0.7s ease-out both;
318
+ }
319
+
320
+ /* Footer link hover */
321
+ .footer-link {
322
+ color: #A1A1AA;
323
+ transition: color 0.2s ease;
324
+ cursor: pointer;
325
+ }
326
+ .footer-link:hover {
327
+ color: #F59E0B;
328
+ }
329
+
330
+ /* Typing dots animation */
331
+ @keyframes typingDot {
332
+ 0%, 60%, 100% { opacity: 0.3; }
333
+ 30% { opacity: 1; }
334
+ }
335
+ .typing-dot {
336
+ animation: typingDot 1.4s ease-in-out infinite;
337
+ }
338
+ .typing-dot:nth-child(2) { animation-delay: 0.2s; }
339
+ .typing-dot:nth-child(3) { animation-delay: 0.4s; }
340
+
341
+ /* ========================================
342
+ Enhanced Button Styles
343
+ ======================================== */
344
+
345
+ /* Primary button with gradient */
346
+ .btn-primary {
347
+ background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%);
348
+ color: #09090B;
349
+ font-weight: 600;
350
+ padding: 10px 24px;
351
+ border-radius: 10px;
352
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
353
+ position: relative;
354
+ overflow: hidden;
355
+ }
356
+ .btn-primary:hover {
357
+ transform: translateY(-1px);
358
+ box-shadow: 0 4px 20px rgba(245, 158, 11, 0.35);
359
+ }
360
+ .btn-primary:active {
361
+ transform: translateY(0);
362
+ }
363
+ .btn-primary:disabled {
364
+ opacity: 0.5;
365
+ cursor: not-allowed;
366
+ transform: none;
367
+ box-shadow: none;
368
+ }
369
+
370
+ /* Secondary/ghost button */
371
+ .btn-ghost {
372
+ background: transparent;
373
+ color: #FAFAFA;
374
+ font-weight: 500;
375
+ padding: 10px 24px;
376
+ border-radius: 10px;
377
+ border: 1px solid rgba(255, 255, 255, 0.1);
378
+ transition: all 0.25s ease;
379
+ }
380
+ .btn-ghost:hover {
381
+ background: rgba(255, 255, 255, 0.05);
382
+ border-color: rgba(255, 255, 255, 0.2);
383
+ }
384
+
385
+ /* ========================================
386
+ Form Input Enhancements
387
+ ======================================== */
388
+
389
+ /* Custom form inputs */
390
+ .input-field {
391
+ background: rgba(255, 255, 255, 0.04);
392
+ border: 1px solid rgba(255, 255, 255, 0.08);
393
+ border-radius: 10px;
394
+ padding: 12px 16px;
395
+ color: #FAFAFA;
396
+ font-size: 14px;
397
+ transition: all 0.2s ease;
398
+ width: 100%;
399
+ }
400
+ .input-field:focus {
401
+ outline: none;
402
+ border-color: #F59E0B;
403
+ box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1);
404
+ background: rgba(255, 255, 255, 0.06);
405
+ }
406
+ .input-field::placeholder {
407
+ color: #52525B;
408
+ }
409
+
410
+ /* Input with floating label */
411
+ .input-group {
412
+ position: relative;
413
+ }
414
+ .input-label {
415
+ display: block;
416
+ font-size: 13px;
417
+ font-weight: 500;
418
+ color: #A1A1AA;
419
+ margin-bottom: 6px;
420
+ }
421
+
422
+ /* ========================================
423
+ Enhanced Card Styles
424
+ ======================================== */
425
+
426
+ /* Card with colored top border accent */
427
+ .card-accent-amber { border-top: 2px solid rgba(245, 158, 11, 0.6); }
428
+ .card-accent-teal { border-top: 2px solid rgba(20, 184, 166, 0.6); }
429
+ .card-accent-red { border-top: 2px solid rgba(239, 68, 68, 0.6); }
430
+ .card-accent-green { border-top: 2px solid rgba(34, 197, 94, 0.6); }
431
+
432
+ /* Hover lift effect */
433
+ .hover-lift {
434
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
435
+ }
436
+ .hover-lift:hover {
437
+ transform: translateY(-4px);
438
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
439
+ }
440
+
441
+ /* ========================================
442
+ Badge/Tag Styles
443
+ ======================================== */
444
+
445
+ /* Plan badge */
446
+ .badge-amber {
447
+ background: rgba(245, 158, 11, 0.12);
448
+ color: #F59E0B;
449
+ padding: 2px 10px;
450
+ border-radius: 100px;
451
+ font-size: 12px;
452
+ font-weight: 600;
453
+ letter-spacing: 0.02em;
454
+ }
455
+ .badge-teal {
456
+ background: rgba(20, 184, 166, 0.12);
457
+ color: #14B8A6;
458
+ padding: 2px 10px;
459
+ border-radius: 100px;
460
+ font-size: 12px;
461
+ font-weight: 600;
462
+ letter-spacing: 0.02em;
463
+ }
464
+ .badge-muted {
465
+ background: rgba(255, 255, 255, 0.06);
466
+ color: #A1A1AA;
467
+ padding: 2px 10px;
468
+ border-radius: 100px;
469
+ font-size: 12px;
470
+ font-weight: 500;
471
+ }
472
+
473
+ /* ========================================
474
+ Tab Navigation Enhancement
475
+ ======================================== */
476
+
477
+ /* Dashboard tab styles */
478
+ .dash-tab {
479
+ padding: 10px 16px;
480
+ font-size: 14px;
481
+ font-weight: 500;
482
+ color: #71717A;
483
+ border-bottom: 2px solid transparent;
484
+ transition: all 0.2s ease;
485
+ cursor: pointer;
486
+ white-space: nowrap;
487
+ }
488
+ .dash-tab:hover {
489
+ color: #FAFAFA;
490
+ background: rgba(255, 255, 255, 0.03);
491
+ }
492
+ .dash-tab.active {
493
+ color: #F59E0B;
494
+ border-bottom-color: #F59E0B;
495
+ }
496
+
497
+ /* ========================================
498
+ Tooltip Enhancement
499
+ ======================================== */
500
+
501
+ /* Custom tooltip */
502
+ .tooltip-wrapper {
503
+ position: relative;
504
+ }
505
+ .tooltip-wrapper .tooltip-text {
506
+ visibility: hidden;
507
+ opacity: 0;
508
+ position: absolute;
509
+ bottom: calc(100% + 8px);
510
+ left: 50%;
511
+ transform: translateX(-50%) translateY(4px);
512
+ background: #27272A;
513
+ color: #FAFAFA;
514
+ padding: 6px 12px;
515
+ border-radius: 8px;
516
+ font-size: 12px;
517
+ white-space: nowrap;
518
+ z-index: 50;
519
+ border: 1px solid rgba(255, 255, 255, 0.08);
520
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
521
+ transition: all 0.2s ease;
522
+ pointer-events: none;
523
+ }
524
+ .tooltip-wrapper:hover .tooltip-text {
525
+ visibility: visible;
526
+ opacity: 1;
527
+ transform: translateX(-50%) translateY(0);
528
+ }
529
+
530
+ /* ========================================
531
+ Animation Enhancements
532
+ ======================================== */
533
+
534
+ /* Pulse glow for CTA elements */
535
+ @keyframes pulseGlow {
536
+ 0%, 100% { box-shadow: 0 0 20px rgba(245, 158, 11, 0.2); }
537
+ 50% { box-shadow: 0 0 40px rgba(245, 158, 11, 0.4); }
538
+ }
539
+ .pulse-glow {
540
+ animation: pulseGlow 3s ease-in-out infinite;
541
+ }
542
+
543
+ /* Slide in from right */
544
+ @keyframes slideInRight {
545
+ from { opacity: 0; transform: translateX(20px); }
546
+ to { opacity: 1; transform: translateX(0); }
547
+ }
548
+ .slide-in-right {
549
+ animation: slideInRight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
550
+ }
551
+
552
+ /* Scale in */
553
+ @keyframes scaleIn {
554
+ from { opacity: 0; transform: scale(0.95); }
555
+ to { opacity: 1; transform: scale(1); }
556
+ }
557
+ .scale-in {
558
+ animation: scaleIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
559
+ }
560
+
561
+ /* Stagger children */
562
+ .stagger-children > *:nth-child(1) { animation-delay: 0ms; }
563
+ .stagger-children > *:nth-child(2) { animation-delay: 80ms; }
564
+ .stagger-children > *:nth-child(3) { animation-delay: 160ms; }
565
+ .stagger-children > *:nth-child(4) { animation-delay: 240ms; }
566
+ .stagger-children > *:nth-child(5) { animation-delay: 320ms; }
567
+ .stagger-children > *:nth-child(6) { animation-delay: 400ms; }
568
+ .stagger-children > * {
569
+ opacity: 0;
570
+ animation: fadeIn 0.5s ease forwards;
571
+ }
572
+
573
+ /* ========================================
574
+ Table Enhancements
575
+ ======================================== */
576
+
577
+ /* Modern data table */
578
+ .data-table {
579
+ width: 100%;
580
+ border-collapse: separate;
581
+ border-spacing: 0;
582
+ }
583
+ .data-table thead th {
584
+ padding: 12px 16px;
585
+ text-align: left;
586
+ font-size: 12px;
587
+ font-weight: 600;
588
+ text-transform: uppercase;
589
+ letter-spacing: 0.05em;
590
+ color: #71717A;
591
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
592
+ position: sticky;
593
+ top: 0;
594
+ background: #111113;
595
+ z-index: 1;
596
+ }
597
+ .data-table tbody td {
598
+ padding: 12px 16px;
599
+ font-size: 14px;
600
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
601
+ color: #D4D4D8;
602
+ }
603
+ .data-table tbody tr:last-child td {
604
+ border-bottom: none;
605
+ }
606
+
607
+ /* ========================================
608
+ Code Block Style (for API docs)
609
+ ======================================== */
610
+
611
+ .code-block {
612
+ background: #0C0C0E;
613
+ border: 1px solid rgba(255, 255, 255, 0.06);
614
+ border-radius: 10px;
615
+ padding: 16px 20px;
616
+ font-family: 'Geist Mono', monospace;
617
+ font-size: 13px;
618
+ line-height: 1.6;
619
+ color: #A1A1AA;
620
+ overflow-x: auto;
621
+ }
622
+ .code-block .code-keyword { color: #F59E0B; }
623
+ .code-block .code-string { color: #14B8A6; }
624
+ .code-block .code-comment { color: #52525B; }
625
+
626
+ /* ========================================
627
+ Scroll-to-top button
628
+ ======================================== */
629
+
630
+ .scroll-top-btn {
631
+ position: fixed;
632
+ bottom: 24px;
633
+ right: 24px;
634
+ width: 44px;
635
+ height: 44px;
636
+ border-radius: 12px;
637
+ background: rgba(245, 158, 11, 0.15);
638
+ border: 1px solid rgba(245, 158, 11, 0.3);
639
+ color: #F59E0B;
640
+ display: flex;
641
+ align-items: center;
642
+ justify-content: center;
643
+ cursor: pointer;
644
+ opacity: 0;
645
+ transform: translateY(8px);
646
+ transition: all 0.3s ease;
647
+ z-index: 40;
648
+ }
649
+ .scroll-top-btn.visible {
650
+ opacity: 1;
651
+ transform: translateY(0);
652
+ }
653
+ .scroll-top-btn:hover {
654
+ background: rgba(245, 158, 11, 0.25);
655
+ transform: translateY(-2px);
656
+ }
657
+
658
+ /* ========================================
659
+ Empty State
660
+ ======================================== */
661
+
662
+ .empty-state {
663
+ display: flex;
664
+ flex-direction: column;
665
+ align-items: center;
666
+ justify-content: center;
667
+ padding: 48px 24px;
668
+ color: #52525B;
669
+ }
670
+ .empty-state svg {
671
+ width: 48px;
672
+ height: 48px;
673
+ margin-bottom: 16px;
674
+ opacity: 0.4;
675
+ }
676
+
677
+ /* ========================================
678
+ Navbar blur
679
+ ======================================== */
680
+
681
+ .nav-blur {
682
+ backdrop-filter: blur(16px) saturate(180%);
683
+ background: rgba(9, 9, 11, 0.8);
684
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
685
+ }
686
+
687
+ /* ========================================
688
+ Detail panel slide-over
689
+ ======================================== */
690
+
691
+ .detail-panel {
692
+ animation: slideInRight 0.3s ease;
693
+ border-left: 1px solid rgba(255, 255, 255, 0.06);
694
+ }
695
+
696
+ /* ========================================
697
+ Notification badge
698
+ ======================================== */
699
+
700
+ .notification-badge {
701
+ position: absolute;
702
+ top: -2px;
703
+ right: -2px;
704
+ width: 8px;
705
+ height: 8px;
706
+ background: #EF4444;
707
+ border-radius: 50%;
708
+ border: 2px solid #09090B;
709
+ }
710
+
711
+ /* ========================================
712
+ Animated Drop Zone
713
+ ======================================== */
714
+
715
+ @keyframes dashRotate {
716
+ to { stroke-dashoffset: -20; }
717
+ }
718
+ .drop-zone-animated {
719
+ border: 2px dashed rgba(245, 158, 11, 0.3);
720
+ border-radius: 16px;
721
+ transition: all 0.3s ease;
722
+ background-image: url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg stroke='%23F59E0B' stroke-opacity='0.04' stroke-width='1'%3E%3Cpath d='M0 20h40M20 0v40'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
723
+ }
724
+ .drop-zone-animated:hover {
725
+ border-color: rgba(245, 158, 11, 0.5);
726
+ background: rgba(245, 158, 11, 0.03);
727
+ }
728
+
729
+ /* ========================================
730
+ Notification Dropdown
731
+ ======================================== */
732
+
733
+ .notification-dropdown {
734
+ position: absolute;
735
+ top: calc(100% + 8px);
736
+ right: 0;
737
+ width: 320px;
738
+ background: #18181B;
739
+ border: 1px solid rgba(255,255,255,0.08);
740
+ border-radius: 12px;
741
+ box-shadow: 0 16px 48px rgba(0,0,0,0.5);
742
+ z-index: 100;
743
+ overflow: hidden;
744
+ }
745
+ .notification-item {
746
+ padding: 12px 16px;
747
+ border-bottom: 1px solid rgba(255,255,255,0.04);
748
+ transition: background 0.15s ease;
749
+ }
750
+ .notification-item:hover {
751
+ background: rgba(255,255,255,0.03);
752
+ }
753
+ .notification-item:last-child {
754
+ border-bottom: none;
755
+ }
756
+
757
+ /* ========================================
758
+ Modal / Dialog Overlay
759
+ ======================================== */
760
+
761
+ .modal-overlay {
762
+ position: fixed;
763
+ inset: 0;
764
+ background: rgba(0,0,0,0.6);
765
+ backdrop-filter: blur(4px);
766
+ z-index: 100;
767
+ display: flex;
768
+ align-items: center;
769
+ justify-content: center;
770
+ animation: fadeIn 0.2s ease;
771
+ }
772
+ .modal-content {
773
+ background: #18181B;
774
+ border: 1px solid rgba(255,255,255,0.08);
775
+ border-radius: 16px;
776
+ padding: 24px;
777
+ max-width: 480px;
778
+ width: 90%;
779
+ animation: scaleIn 0.2s ease;
780
+ }
781
+
782
+ /* ========================================
783
+ Enhanced Chat Message Styles
784
+ ======================================== */
785
+
786
+ .chat-message-user {
787
+ background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%);
788
+ color: #09090B;
789
+ border-radius: 20px 20px 6px 20px;
790
+ padding: 12px 16px;
791
+ max-width: 80%;
792
+ }
793
+ .chat-message-assistant {
794
+ background: rgba(255,255,255,0.06);
795
+ color: #FAFAFA;
796
+ border-radius: 20px 20px 20px 6px;
797
+ padding: 12px 16px;
798
+ max-width: 80%;
799
+ border: 1px solid rgba(255,255,255,0.06);
800
+ }
801
+ .chat-timestamp {
802
+ font-size: 11px;
803
+ color: rgba(255,255,255,0.3);
804
+ margin-top: 4px;
805
+ }
806
+
807
+ /* ========================================
808
+ Button Ripple / Pulse Effect
809
+ ======================================== */
810
+
811
+ .btn-primary::after {
812
+ content: '';
813
+ position: absolute;
814
+ inset: 0;
815
+ border-radius: inherit;
816
+ opacity: 0;
817
+ transition: opacity 0.3s ease;
818
+ background: radial-gradient(circle at center, rgba(255,255,255,0.2) 0%, transparent 70%);
819
+ }
820
+ .btn-primary:active::after {
821
+ opacity: 1;
822
+ transition: opacity 0s;
823
+ }
824
+
825
+ /* ========================================
826
+ Keyboard Shortcut Badge
827
+ ======================================== */
828
+
829
+ .kbd {
830
+ display: inline-flex;
831
+ align-items: center;
832
+ justify-content: center;
833
+ padding: 2px 6px;
834
+ font-size: 11px;
835
+ font-family: 'Geist Mono', monospace;
836
+ color: #A1A1AA;
837
+ background: rgba(255,255,255,0.06);
838
+ border: 1px solid rgba(255,255,255,0.1);
839
+ border-radius: 6px;
840
+ box-shadow: 0 1px 0 rgba(255,255,255,0.05);
841
+ line-height: 1.4;
842
+ }
843
+
844
+ /* ========================================
845
+ Checkbox Styling (Batch Operations)
846
+ ======================================== */
847
+
848
+ .invoice-checkbox {
849
+ appearance: none;
850
+ width: 16px;
851
+ height: 16px;
852
+ border: 1.5px solid rgba(255,255,255,0.2);
853
+ border-radius: 4px;
854
+ background: transparent;
855
+ cursor: pointer;
856
+ transition: all 0.15s ease;
857
+ position: relative;
858
+ }
859
+ .invoice-checkbox:checked {
860
+ background: #F59E0B;
861
+ border-color: #F59E0B;
862
+ }
863
+ .invoice-checkbox:checked::after {
864
+ content: '';
865
+ position: absolute;
866
+ left: 4px;
867
+ top: 1px;
868
+ width: 5px;
869
+ height: 9px;
870
+ border: solid #09090B;
871
+ border-width: 0 2px 2px 0;
872
+ transform: rotate(45deg);
873
+ }
874
+ .invoice-checkbox:hover {
875
+ border-color: rgba(245,158,11,0.5);
876
+ }
877
+
878
+ /* ========================================
879
+ Dashboard Footer
880
+ ======================================== */
881
+
882
+ .dash-footer {
883
+ border-top: 1px solid rgba(255,255,255,0.06);
884
+ background: rgba(255,255,255,0.01);
885
+ margin-top: auto;
886
+ }
887
+
888
+ /* ========================================
889
+ Focus Glow (Subtle Glow on Focus)
890
+ ======================================== */
891
+
892
+ .focus-glow:focus-visible {
893
+ box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15);
894
+ border-color: rgba(245, 158, 11, 0.4);
895
+ }
896
+
897
+ /* ========================================
898
+ Animated Counter (Number Pop)
899
+ ======================================== */
900
+
901
+ @keyframes numberPop {
902
+ 0% { transform: scale(0.8); opacity: 0; }
903
+ 60% { transform: scale(1.05); }
904
+ 100% { transform: scale(1); opacity: 1; }
905
+ }
906
+ .number-pop {
907
+ animation: numberPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
908
+ }
909
+
910
+ /* ========================================
911
+ Select Dropdown Styling
912
+ ======================================== */
913
+
914
+ .select-field {
915
+ background: rgba(255, 255, 255, 0.04);
916
+ border: 1px solid rgba(255, 255, 255, 0.08);
917
+ border-radius: 10px;
918
+ padding: 10px 16px;
919
+ color: #FAFAFA;
920
+ font-size: 14px;
921
+ transition: all 0.2s ease;
922
+ width: 100%;
923
+ appearance: none;
924
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23A1A1AA' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
925
+ background-repeat: no-repeat;
926
+ background-position: right 12px center;
927
+ padding-right: 36px;
928
+ }
929
+ .select-field:focus {
930
+ outline: none;
931
+ border-color: #F59E0B;
932
+ box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1);
933
+ }
934
+
935
+ /* ========================================
936
+ Chat Suggestion Chip Hover Glow
937
+ ======================================== */
938
+
939
+ .chat-suggestion:hover {
940
+ box-shadow: 0 0 12px rgba(245, 158, 11, 0.1);
941
+ border-color: rgba(245, 158, 11, 0.2);
942
+ }
943
+
944
+ /* ========================================
945
+ Safe Area Padding for iOS
946
+ ======================================== */
947
+
948
+ .pb-safe {
949
+ padding-bottom: max(16px, env(safe-area-inset-bottom));
950
+ }
951
+
952
+ /* ========================================
953
+ View Transition & Micro-interactions
954
+ ======================================== */
955
+
956
+ /* Page/View transition */
957
+ @keyframes viewEnter {
958
+ from {
959
+ opacity: 0;
960
+ transform: translateY(12px) scale(0.99);
961
+ }
962
+ to {
963
+ opacity: 1;
964
+ transform: translateY(0) scale(1);
965
+ }
966
+ }
967
+ .view-enter {
968
+ animation: viewEnter 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
969
+ }
970
+
971
+ /* Staggered card entrance */
972
+ @keyframes cardEnter {
973
+ from {
974
+ opacity: 0;
975
+ transform: translateY(16px);
976
+ }
977
+ to {
978
+ opacity: 1;
979
+ transform: translateY(0);
980
+ }
981
+ }
982
+ .card-enter {
983
+ opacity: 0;
984
+ animation: cardEnter 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
985
+ }
986
+ .card-enter:nth-child(1) { animation-delay: 0ms; }
987
+ .card-enter:nth-child(2) { animation-delay: 60ms; }
988
+ .card-enter:nth-child(3) { animation-delay: 120ms; }
989
+ .card-enter:nth-child(4) { animation-delay: 180ms; }
990
+ .card-enter:nth-child(5) { animation-delay: 240ms; }
991
+ .card-enter:nth-child(6) { animation-delay: 300ms; }
992
+
993
+ /* Animated Stat Counter (for landing page stats) */
994
+ @keyframes countSlideUp {
995
+ from { opacity: 0; transform: translateY(20px); }
996
+ to { opacity: 1; transform: translateY(0); }
997
+ }
998
+ .stat-animate {
999
+ opacity: 0;
1000
+ animation: countSlideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
1001
+ }
1002
+ .stat-animate:nth-child(1) { animation-delay: 0.1s; }
1003
+ .stat-animate:nth-child(2) { animation-delay: 0.2s; }
1004
+ .stat-animate:nth-child(3) { animation-delay: 0.3s; }
1005
+
1006
+ /* Enhanced Feature Cards with Gradient Border on Hover */
1007
+ .feature-card-gradient {
1008
+ position: relative;
1009
+ background: rgba(255,255,255,0.03);
1010
+ backdrop-filter: blur(12px);
1011
+ border: 1px solid rgba(255,255,255,0.06);
1012
+ border-radius: 16px;
1013
+ overflow: hidden;
1014
+ transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
1015
+ }
1016
+ .feature-card-gradient::before {
1017
+ content: '';
1018
+ position: absolute;
1019
+ inset: 0;
1020
+ border-radius: 16px;
1021
+ padding: 1px;
1022
+ background: linear-gradient(135deg, transparent 40%, rgba(245,158,11,0.3) 100%);
1023
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1024
+ -webkit-mask-composite: xor;
1025
+ mask-composite: exclude;
1026
+ opacity: 0;
1027
+ transition: opacity 0.4s ease;
1028
+ pointer-events: none;
1029
+ }
1030
+ .feature-card-gradient:hover::before {
1031
+ opacity: 1;
1032
+ }
1033
+ .feature-card-gradient:hover {
1034
+ transform: translateY(-6px);
1035
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3), 0 0 40px rgba(245,158,11,0.05);
1036
+ }
1037
+
1038
+ /* Pricing Card "Most Popular" ribbon */
1039
+ .pricing-ribbon {
1040
+ position: absolute;
1041
+ top: -1px;
1042
+ right: 24px;
1043
+ background: linear-gradient(135deg, #F59E0B, #D97706);
1044
+ color: #09090B;
1045
+ font-size: 11px;
1046
+ font-weight: 700;
1047
+ padding: 4px 12px 6px;
1048
+ border-radius: 0 0 8px 8px;
1049
+ letter-spacing: 0.04em;
1050
+ text-transform: uppercase;
1051
+ }
1052
+
1053
+ /* Testimonial Card Enhancements */
1054
+ .testimonial-card {
1055
+ position: relative;
1056
+ background: rgba(255,255,255,0.03);
1057
+ backdrop-filter: blur(12px);
1058
+ border: 1px solid rgba(255,255,255,0.06);
1059
+ border-radius: 16px;
1060
+ padding: 24px;
1061
+ transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
1062
+ }
1063
+ .testimonial-card:hover {
1064
+ transform: translateY(-4px) scale(1.01);
1065
+ border-color: rgba(245,158,11,0.15);
1066
+ box-shadow: 0 16px 48px rgba(0,0,0,0.2);
1067
+ }
1068
+ .testimonial-card::after {
1069
+ content: '';
1070
+ position: absolute;
1071
+ bottom: 0;
1072
+ left: 24px;
1073
+ right: 24px;
1074
+ height: 2px;
1075
+ background: linear-gradient(90deg, transparent, rgba(245,158,11,0.3), transparent);
1076
+ opacity: 0;
1077
+ transition: opacity 0.4s ease;
1078
+ }
1079
+ .testimonial-card:hover::after {
1080
+ opacity: 1;
1081
+ }
1082
+
1083
+ /* Hero Particle/Dot Grid Enhancement */
1084
+ .hero-grid-enhanced {
1085
+ background-image:
1086
+ radial-gradient(circle at 1px 1px, rgba(245,158,11,0.08) 1px, transparent 0);
1087
+ background-size: 40px 40px;
1088
+ }
1089
+
1090
+ /* Glow divider between sections */
1091
+ .section-glow-divider {
1092
+ height: 1px;
1093
+ background: linear-gradient(90deg, transparent, rgba(245,158,11,0.2) 50%, transparent);
1094
+ border: none;
1095
+ }
1096
+
1097
+ /* Tab content area subtle background */
1098
+ .tab-content-area {
1099
+ background: linear-gradient(180deg, rgba(255,255,255,0.01) 0%, transparent 100%);
1100
+ border-radius: 12px;
1101
+ padding: 24px;
1102
+ }
1103
+
1104
+ /* Activity timeline line animation */
1105
+ @keyframes timelineGrow {
1106
+ from { transform: scaleY(0); }
1107
+ to { transform: scaleY(1); }
1108
+ }
1109
+ .timeline-line-animated {
1110
+ transform-origin: top;
1111
+ animation: timelineGrow 0.8s cubic-bezier(0.16, 1, 0.3, 1) both;
1112
+ animation-delay: 0.3s;
1113
+ }
1114
+
1115
+ /* Enhanced input focus with glow ring */
1116
+ .input-field-glow:focus {
1117
+ outline: none;
1118
+ border-color: rgba(245, 158, 11, 0.5);
1119
+ box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.08), 0 0 20px rgba(245, 158, 11, 0.06);
1120
+ background: rgba(255, 255, 255, 0.06);
1121
+ }
1122
+
1123
+ /* Subtle hover underline for footer links */
1124
+ .footer-link-underline {
1125
+ position: relative;
1126
+ color: #A1A1AA;
1127
+ transition: color 0.2s ease;
1128
+ cursor: pointer;
1129
+ }
1130
+ .footer-link-underline::after {
1131
+ content: '';
1132
+ position: absolute;
1133
+ bottom: -2px;
1134
+ left: 0;
1135
+ width: 0;
1136
+ height: 1px;
1137
+ background: #F59E0B;
1138
+ transition: width 0.3s ease;
1139
+ }
1140
+ .footer-link-underline:hover {
1141
+ color: #F59E0B;
1142
+ }
1143
+ .footer-link-underline:hover::after {
1144
+ width: 100%;
1145
+ }
1146
+
1147
+ /* ── Premium CSS Enhancements ── */
1148
+
1149
+ /* CTA button shine sweep effect */
1150
+ .btn-shine {
1151
+ position: relative;
1152
+ overflow: hidden;
1153
+ }
1154
+ .btn-shine::before {
1155
+ content: '';
1156
+ position: absolute;
1157
+ top: 0;
1158
+ left: -100%;
1159
+ width: 100%;
1160
+ height: 100%;
1161
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
1162
+ transition: left 0.5s ease;
1163
+ }
1164
+ .btn-shine:hover::before {
1165
+ left: 100%;
1166
+ }
1167
+
1168
+ /* Floating badge animation on hero */
1169
+ @keyframes float {
1170
+ 0%, 100% { transform: translateY(0px); }
1171
+ 50% { transform: translateY(-8px); }
1172
+ }
1173
+ .float-animation {
1174
+ animation: float 3s ease-in-out infinite;
1175
+ }
1176
+ .float-animation-delayed {
1177
+ animation: float 3s ease-in-out 1.5s infinite;
1178
+ }
1179
+
1180
+ /* Logo pulse glow */
1181
+ @keyframes logoGlow {
1182
+ 0%, 100% { box-shadow: 0 0 12px rgba(245, 158, 11, 0.2); }
1183
+ 50% { box-shadow: 0 0 24px rgba(245, 158, 11, 0.4); }
1184
+ }
1185
+ .logo-glow {
1186
+ animation: logoGlow 3s ease-in-out infinite;
1187
+ }
1188
+
1189
+ /* Enhanced glassmorphism for stat cards */
1190
+ .stat-card-glass {
1191
+ background: rgba(255, 255, 255, 0.03);
1192
+ backdrop-filter: blur(12px);
1193
+ border: 1px solid rgba(255, 255, 255, 0.06);
1194
+ border-radius: 12px;
1195
+ padding: 16px 20px;
1196
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
1197
+ position: relative;
1198
+ overflow: hidden;
1199
+ }
1200
+ .stat-card-glass::before {
1201
+ content: '';
1202
+ position: absolute;
1203
+ top: 0;
1204
+ left: 0;
1205
+ right: 0;
1206
+ height: 2px;
1207
+ border-radius: 12px 12px 0 0;
1208
+ }
1209
+ .stat-card-glass.card-accent-amber::before {
1210
+ background: linear-gradient(90deg, transparent, rgba(245, 158, 11, 0.6), transparent);
1211
+ }
1212
+ .stat-card-glass.card-accent-teal::before {
1213
+ background: linear-gradient(90deg, transparent, rgba(20, 184, 166, 0.6), transparent);
1214
+ }
1215
+ .stat-card-glass.card-accent-red::before {
1216
+ background: linear-gradient(90deg, transparent, rgba(239, 68, 68, 0.6), transparent);
1217
+ }
1218
+ .stat-card-glass.card-accent-green::before {
1219
+ background: linear-gradient(90deg, transparent, rgba(34, 197, 94, 0.6), transparent);
1220
+ }
1221
+ .stat-card-glass:hover {
1222
+ transform: translateY(-3px);
1223
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
1224
+ border-color: rgba(255, 255, 255, 0.1);
1225
+ }
1226
+
1227
+ /* Enhanced table row hover with left accent bar */
1228
+ .invoice-row-enhanced {
1229
+ cursor: pointer;
1230
+ transition: all 0.2s ease;
1231
+ position: relative;
1232
+ }
1233
+ .invoice-row-enhanced:hover {
1234
+ background: rgba(255, 255, 255, 0.03) !important;
1235
+ }
1236
+ .invoice-row-enhanced:hover td:first-child {
1237
+ box-shadow: inset 3px 0 0 -1px rgba(245, 158, 11, 0.5);
1238
+ }
1239
+
1240
+ /* Custom recharts tooltip */
1241
+ .recharts-tooltip-custom {
1242
+ background: rgba(17, 17, 19, 0.95) !important;
1243
+ border: 1px solid rgba(255, 255, 255, 0.08) !important;
1244
+ border-radius: 10px !important;
1245
+ padding: 10px 14px !important;
1246
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3) !important;
1247
+ backdrop-filter: blur(8px) !important;
1248
+ }
1249
+ .recharts-tooltip-custom .recharts-tooltip-label {
1250
+ color: #A1A1AA !important;
1251
+ font-size: 11px !important;
1252
+ font-weight: 500 !important;
1253
+ text-transform: uppercase !important;
1254
+ letter-spacing: 0.05em !important;
1255
+ }
1256
+ .recharts-tooltip-custom .recharts-tooltip-value {
1257
+ color: #FAFAFA !important;
1258
+ font-size: 13px !important;
1259
+ font-weight: 600 !important;
1260
+ }
1261
+
1262
+ /* Smooth mobile nav slide down */
1263
+ @keyframes slideDown {
1264
+ from { opacity: 0; transform: translateY(-8px); max-height: 0; }
1265
+ to { opacity: 1; transform: translateY(0); max-height: 400px; }
1266
+ }
1267
+ .mobile-nav-slide {
1268
+ animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
1269
+ overflow: hidden;
1270
+ }
1271
+
1272
+ /* How-it-works step connector line */
1273
+ .step-connector {
1274
+ position: relative;
1275
+ }
1276
+ .step-connector::after {
1277
+ content: '';
1278
+ position: absolute;
1279
+ top: 28px;
1280
+ left: calc(50% + 32px);
1281
+ width: calc(100% - 64px);
1282
+ height: 2px;
1283
+ background: linear-gradient(90deg, rgba(245, 158, 11, 0.3), rgba(20, 184, 166, 0.3));
1284
+ }
1285
+ .step-connector:last-child::after {
1286
+ display: none;
1287
+ }
1288
+
1289
+ /* Upload success animation */
1290
+ @keyframes successPop {
1291
+ 0% { transform: scale(0.8); opacity: 0; }
1292
+ 50% { transform: scale(1.05); }
1293
+ 100% { transform: scale(1); opacity: 1; }
1294
+ }
1295
+ .success-pop {
1296
+ animation: successPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
1297
+ }
1298
+
1299
+ /* Subtle hover effect for FAQ details */
1300
+ details.glass-card summary {
1301
+ transition: background 0.2s ease;
1302
+ }
1303
+ details.glass-card summary:hover {
1304
+ background: rgba(255, 255, 255, 0.02);
1305
+ }
1306
+ details.glass-card[open] summary {
1307
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
1308
+ }
1309
+
1310
+ /* ========================================
1311
+ Light Theme Overrides
1312
+ ======================================== */
1313
+
1314
+ /* Base light theme */
1315
+ .light {
1316
+ --background: #FAFAFA;
1317
+ --foreground: #18181B;
1318
+ --card: #FFFFFF;
1319
+ --card-foreground: #18181B;
1320
+ --popover: #FFFFFF;
1321
+ --popover-foreground: #18181B;
1322
+ --primary: #D97706;
1323
+ --primary-foreground: #FFFFFF;
1324
+ --secondary: #F4F4F5;
1325
+ --secondary-foreground: #18181B;
1326
+ --muted: #F4F4F5;
1327
+ --muted-foreground: #71717A;
1328
+ --accent: #F4F4F5;
1329
+ --accent-foreground: #18181B;
1330
+ --destructive: #EF4444;
1331
+ --border: rgba(0, 0, 0, 0.08);
1332
+ --input: rgba(0, 0, 0, 0.08);
1333
+ --ring: #D97706;
1334
+ color-scheme: light;
1335
+ }
1336
+
1337
+ .light body {
1338
+ background: #FAFAFA;
1339
+ color: #18181B;
1340
+ }
1341
+
1342
+ /* Light glass cards */
1343
+ .light .glass-card,
1344
+ .light .glass-card-hover {
1345
+ background: rgba(255, 255, 255, 0.8);
1346
+ border-color: rgba(0, 0, 0, 0.06);
1347
+ backdrop-filter: blur(12px);
1348
+ }
1349
+
1350
+ .light .glass-card-hover:hover {
1351
+ background: rgba(255, 255, 255, 0.95);
1352
+ border-color: rgba(217, 119, 6, 0.2);
1353
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.06);
1354
+ }
1355
+
1356
+ /* Light navbar */
1357
+ .light .nav-blur {
1358
+ background: rgba(250, 250, 250, 0.85);
1359
+ border-bottom-color: rgba(0, 0, 0, 0.06);
1360
+ }
1361
+
1362
+ /* Light input fields */
1363
+ .light .input-field,
1364
+ .light .input-field-glow {
1365
+ background: #FFFFFF;
1366
+ border-color: rgba(0, 0, 0, 0.1);
1367
+ color: #18181B;
1368
+ }
1369
+ .light .input-field:focus,
1370
+ .light .input-field-glow:focus {
1371
+ border-color: #D97706;
1372
+ box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.1), 0 0 20px rgba(217, 119, 6, 0.04);
1373
+ background: #FFFFFF;
1374
+ }
1375
+ .light .input-field::placeholder,
1376
+ .light .input-field-glow::placeholder {
1377
+ color: #A1A1AA;
1378
+ }
1379
+
1380
+ /* Light buttons */
1381
+ .light .btn-ghost {
1382
+ color: #18181B;
1383
+ border-color: rgba(0, 0, 0, 0.1);
1384
+ }
1385
+ .light .btn-ghost:hover {
1386
+ background: rgba(0, 0, 0, 0.04);
1387
+ border-color: rgba(0, 0, 0, 0.15);
1388
+ }
1389
+ .light .btn-primary {
1390
+ background: linear-gradient(135deg, #D97706 0%, #B45309 100%);
1391
+ color: #FFFFFF;
1392
+ }
1393
+
1394
+ /* Light data table */
1395
+ .light .data-table thead th {
1396
+ color: #71717A;
1397
+ border-bottom-color: rgba(0, 0, 0, 0.06);
1398
+ background: #FAFAFA;
1399
+ }
1400
+ .light .data-table tbody td {
1401
+ color: #52525B;
1402
+ border-bottom-color: rgba(0, 0, 0, 0.04);
1403
+ }
1404
+
1405
+ /* Light stat card */
1406
+ .light .stat-card {
1407
+ background: #FFFFFF;
1408
+ border-color: rgba(0, 0, 0, 0.06);
1409
+ }
1410
+ .light .stat-card:hover {
1411
+ background: #FAFAFA;
1412
+ border-color: rgba(0, 0, 0, 0.1);
1413
+ }
1414
+
1415
+ /* Light drop zone */
1416
+ .light .drop-zone,
1417
+ .light .drop-zone-animated {
1418
+ border-color: rgba(217, 119, 6, 0.3);
1419
+ background: rgba(217, 119, 6, 0.02);
1420
+ }
1421
+ .light .drop-zone:hover,
1422
+ .light .drop-zone.drag-over {
1423
+ border-color: #D97706;
1424
+ background: rgba(217, 119, 6, 0.04);
1425
+ }
1426
+
1427
+ /* Light invoice row */
1428
+ .light .invoice-row:hover {
1429
+ background: rgba(0, 0, 0, 0.02) !important;
1430
+ }
1431
+
1432
+ /* Light code block */
1433
+ .light .code-block {
1434
+ background: #F4F4F5;
1435
+ border-color: rgba(0, 0, 0, 0.06);
1436
+ color: #52525B;
1437
+ }
1438
+
1439
+ /* Light modal */
1440
+ .light .modal-overlay {
1441
+ background: rgba(0, 0, 0, 0.4);
1442
+ }
1443
+ .light .modal-content {
1444
+ background: #FFFFFF;
1445
+ border-color: rgba(0, 0, 0, 0.08);
1446
+ }
1447
+
1448
+ /* Light notification dropdown */
1449
+ .light .notification-dropdown {
1450
+ background: #FFFFFF;
1451
+ border-color: rgba(0, 0, 0, 0.08);
1452
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.12);
1453
+ }
1454
+ .light .notification-item:hover {
1455
+ background: rgba(0, 0, 0, 0.02);
1456
+ }
1457
+
1458
+ /* Light tooltips */
1459
+ .light .tooltip-wrapper .tooltip-text {
1460
+ background: #27272A;
1461
+ color: #FAFAFA;
1462
+ }
1463
+
1464
+ /* Light kbd */
1465
+ .light .kbd {
1466
+ color: #71717A;
1467
+ background: #F4F4F5;
1468
+ border-color: rgba(0, 0, 0, 0.1);
1469
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05);
1470
+ }
1471
+
1472
+ /* Light footer */
1473
+ .light .dash-footer {
1474
+ border-top-color: rgba(0, 0, 0, 0.06);
1475
+ background: rgba(0, 0, 0, 0.01);
1476
+ }
1477
+
1478
+ /* Light scroll-to-top */
1479
+ .light .scroll-top-btn {
1480
+ background: rgba(217, 119, 6, 0.1);
1481
+ border-color: rgba(217, 119, 6, 0.3);
1482
+ color: #D97706;
1483
+ }
1484
+ .light .scroll-top-btn:hover {
1485
+ background: rgba(217, 119, 6, 0.2);
1486
+ }
1487
+
1488
+ /* Light skeleton shimmer */
1489
+ .light .skeleton-shimmer {
1490
+ background: linear-gradient(90deg, rgba(0,0,0,0.03) 25%, rgba(0,0,0,0.07) 50%, rgba(0,0,0,0.03) 75%);
1491
+ background-size: 200% 100%;
1492
+ animation: shimmer 1.5s infinite;
1493
+ border-radius: 8px;
1494
+ }
1495
+
1496
+ /* Light grid background */
1497
+ .light .grid-bg {
1498
+ background-image:
1499
+ linear-gradient(rgba(0,0,0,0.03) 1px, transparent 1px),
1500
+ linear-gradient(90deg, rgba(0,0,0,0.03) 1px, transparent 1px);
1501
+ }
1502
+
1503
+ /* Light hero orbs */
1504
+ .light .hero-orb {
1505
+ background: radial-gradient(circle, rgba(217,119,6,0.08) 0%, rgba(13,148,136,0.04) 40%, transparent 70%);
1506
+ }
1507
+ .light .hero-orb-2 {
1508
+ background: radial-gradient(circle, rgba(13,148,136,0.06) 0%, rgba(217,119,6,0.03) 40%, transparent 70%);
1509
+ }
1510
+
1511
+ /* Light hero dot grid */
1512
+ .light .hero-grid-enhanced {
1513
+ background-image:
1514
+ radial-gradient(circle at 1px 1px, rgba(217,119,6,0.06) 1px, transparent 0);
1515
+ }
1516
+
1517
+ /* Light section glow divider */
1518
+ .light .section-glow-divider {
1519
+ background: linear-gradient(90deg, transparent, rgba(217,119,6,0.15) 50%, transparent);
1520
+ }
1521
+
1522
+ /* Light feature card gradient */
1523
+ .light .feature-card-gradient {
1524
+ background: rgba(255,255,255,0.8);
1525
+ border-color: rgba(0,0,0,0.06);
1526
+ }
1527
+ .light .feature-card-gradient::before {
1528
+ background: linear-gradient(135deg, transparent 40%, rgba(217,119,6,0.2) 100%);
1529
+ }
1530
+ .light .feature-card-gradient:hover {
1531
+ box-shadow: 0 20px 60px rgba(0,0,0,0.08), 0 0 40px rgba(217,119,6,0.03);
1532
+ }
1533
+
1534
+ /* Light testimonial card */
1535
+ .light .testimonial-card {
1536
+ background: rgba(255,255,255,0.8);
1537
+ border-color: rgba(0,0,0,0.06);
1538
+ }
1539
+ .light .testimonial-card:hover {
1540
+ border-color: rgba(217,119,6,0.15);
1541
+ box-shadow: 0 16px 48px rgba(0,0,0,0.08);
1542
+ }
1543
+ .light .testimonial-card::after {
1544
+ background: linear-gradient(90deg, transparent, rgba(217,119,6,0.2), transparent);
1545
+ }
1546
+
1547
+ /* Light pricing highlight */
1548
+ .light .pricing-highlight {
1549
+ border-color: rgba(217,119,6,0.3);
1550
+ background: linear-gradient(135deg, rgba(217,119,6,0.05) 0%, rgba(13,148,136,0.02) 100%);
1551
+ }
1552
+
1553
+ /* Light tab content area */
1554
+ .light .tab-content-area {
1555
+ background: linear-gradient(180deg, rgba(0,0,0,0.01) 0%, transparent 100%);
1556
+ }
1557
+
1558
+ /* Light noise overlay (very subtle) */
1559
+ .light .noise-overlay::before {
1560
+ opacity: 0.015;
1561
+ }
1562
+
1563
+ /* Light select field */
1564
+ .light .select-field {
1565
+ background: #FFFFFF;
1566
+ border-color: rgba(0, 0, 0, 0.1);
1567
+ color: #18181B;
1568
+ }
1569
+ .light .select-field:focus {
1570
+ border-color: #D97706;
1571
+ box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.1);
1572
+ }
1573
+
1574
+ /* Light badge styles */
1575
+ .light .badge-amber {
1576
+ background: rgba(217, 119, 6, 0.1);
1577
+ color: #B45309;
1578
+ }
1579
+ .light .badge-teal {
1580
+ background: rgba(13, 184, 166, 0.1);
1581
+ color: #0D9488;
1582
+ }
1583
+
1584
+ /* Light status badges */
1585
+ .light .status-done { background: rgba(34, 197, 94, 0.1); color: #16A34A; }
1586
+ .light .status-review { background: rgba(234, 179, 8, 0.1); color: #CA8A04; }
1587
+ .light .status-duplicate { background: rgba(239, 68, 68, 0.1); color: #DC2626; }
1588
+
1589
+ /* Light chat bubbles */
1590
+ .light .chat-message-user {
1591
+ background: linear-gradient(135deg, #D97706 0%, #B45309 100%);
1592
+ color: #FFFFFF;
1593
+ }
1594
+ .light .chat-message-assistant {
1595
+ background: rgba(0, 0, 0, 0.04);
1596
+ color: #18181B;
1597
+ border-color: rgba(0, 0, 0, 0.06);
1598
+ }
1599
+ .light .chat-timestamp {
1600
+ color: rgba(0, 0, 0, 0.3);
1601
+ }
1602
+
1603
+ /* Light checkbox */
1604
+ .light .invoice-checkbox {
1605
+ border-color: rgba(0, 0, 0, 0.2);
1606
+ }
1607
+ .light .invoice-checkbox:checked {
1608
+ background: #D97706;
1609
+ border-color: #D97706;
1610
+ }
1611
+ .light .invoice-checkbox:hover {
1612
+ border-color: rgba(217, 119, 6, 0.5);
1613
+ }
1614
+
1615
+ /* Light amber glow */
1616
+ .light .amber-glow {
1617
+ box-shadow: 0 0 40px rgba(217, 119, 6, 0.1), 0 0 80px rgba(217, 119, 6, 0.03);
1618
+ }
1619
+
1620
+ /* Light focus visible */
1621
+ .light *:focus-visible {
1622
+ outline-color: #D97706;
1623
+ }
1624
+
1625
+ /* Light gradient text */
1626
+ .light .gradient-text {
1627
+ background: linear-gradient(135deg, #D97706 0%, #B45309 40%, #0D9488 100%);
1628
+ -webkit-background-clip: text;
1629
+ -webkit-text-fill-color: transparent;
1630
+ background-clip: text;
1631
+ }
1632
+
1633
+ /* Light scrollbar */
1634
+ .light ::-webkit-scrollbar-thumb {
1635
+ background: rgba(0, 0, 0, 0.12);
1636
+ }
1637
+ .light ::-webkit-scrollbar-thumb:hover {
1638
+ background: rgba(0, 0, 0, 0.2);
1639
+ }
1640
+
1641
+ /* ── Table Sort Indicators ── */
1642
+ .sort-header {
1643
+ cursor: pointer;
1644
+ user-select: none;
1645
+ transition: color 0.15s ease;
1646
+ }
1647
+ .sort-header:hover {
1648
+ color: var(--foreground) !important;
1649
+ }
1650
+ .sort-indicator {
1651
+ display: inline-flex;
1652
+ align-items: center;
1653
+ justify-content: center;
1654
+ width: 16px;
1655
+ height: 16px;
1656
+ font-size: 10px;
1657
+ color: #F59E0B;
1658
+ margin-left: 4px;
1659
+ animation: sortPop 0.2s ease;
1660
+ }
1661
+ @keyframes sortPop {
1662
+ 0% { transform: scale(0.5); opacity: 0; }
1663
+ 100% { transform: scale(1); opacity: 1; }
1664
+ }
1665
+ .sort-header-inactive {
1666
+ display: inline-flex;
1667
+ align-items: center;
1668
+ justify-content: center;
1669
+ width: 16px;
1670
+ height: 16px;
1671
+ margin-left: 4px;
1672
+ opacity: 0;
1673
+ transition: opacity 0.15s ease;
1674
+ }
1675
+ .sort-header:hover .sort-header-inactive {
1676
+ opacity: 0.3;
1677
+ }
1678
+
1679
+ /* ── Command Palette ── */
1680
+ .command-palette-backdrop {
1681
+ position: fixed;
1682
+ inset: 0;
1683
+ z-index: 60;
1684
+ background: rgba(0, 0, 0, 0.6);
1685
+ backdrop-filter: blur(8px);
1686
+ display: flex;
1687
+ align-items: flex-start;
1688
+ justify-content: center;
1689
+ padding-top: 15vh;
1690
+ animation: commandFadeIn 0.15s ease;
1691
+ }
1692
+ @keyframes commandFadeIn {
1693
+ from { opacity: 0; }
1694
+ to { opacity: 1; }
1695
+ }
1696
+ .command-palette {
1697
+ width: 100%;
1698
+ max-width: 560px;
1699
+ margin: 0 1rem;
1700
+ background: rgba(24, 24, 27, 0.95);
1701
+ backdrop-filter: blur(20px);
1702
+ border: 1px solid rgba(255, 255, 255, 0.08);
1703
+ border-radius: 16px;
1704
+ box-shadow: 0 25px 60px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.03);
1705
+ overflow: hidden;
1706
+ animation: commandSlideIn 0.2s ease;
1707
+ }
1708
+ @keyframes commandSlideIn {
1709
+ from { opacity: 0; transform: translateY(-10px) scale(0.98); }
1710
+ to { opacity: 1; transform: translateY(0) scale(1); }
1711
+ }
1712
+ .command-palette-input {
1713
+ display: flex;
1714
+ align-items: center;
1715
+ gap: 12px;
1716
+ padding: 16px;
1717
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
1718
+ }
1719
+ .command-palette-input input {
1720
+ flex: 1;
1721
+ background: transparent;
1722
+ border: none;
1723
+ outline: none;
1724
+ color: var(--foreground);
1725
+ font-size: 14px;
1726
+ }
1727
+ .command-palette-input input::placeholder {
1728
+ color: var(--muted-foreground);
1729
+ }
1730
+ .command-palette-results {
1731
+ max-height: 288px;
1732
+ overflow-y: auto;
1733
+ padding: 8px;
1734
+ }
1735
+ .command-palette-item {
1736
+ display: flex;
1737
+ align-items: center;
1738
+ gap: 12px;
1739
+ width: 100%;
1740
+ text-align: left;
1741
+ padding: 10px 12px;
1742
+ border-radius: 10px;
1743
+ font-size: 14px;
1744
+ color: var(--muted-foreground);
1745
+ background: transparent;
1746
+ border: none;
1747
+ cursor: pointer;
1748
+ transition: all 0.12s ease;
1749
+ }
1750
+ .command-palette-item:hover {
1751
+ color: var(--foreground);
1752
+ background: rgba(255, 255, 255, 0.04);
1753
+ }
1754
+ .command-palette-item:active {
1755
+ background: rgba(255, 255, 255, 0.06);
1756
+ }
1757
+ .command-palette-section-title {
1758
+ padding: 8px 12px 4px;
1759
+ font-size: 11px;
1760
+ font-weight: 600;
1761
+ text-transform: uppercase;
1762
+ letter-spacing: 0.05em;
1763
+ color: var(--muted-foreground);
1764
+ }
1765
+ .command-palette-result-sub {
1766
+ font-size: 12px;
1767
+ color: var(--muted-foreground);
1768
+ margin-top: 2px;
1769
+ }
1770
+
1771
+ /* ── Duplicate Alert Panel ── */
1772
+ .duplicate-alert {
1773
+ position: relative;
1774
+ overflow: hidden;
1775
+ border-color: rgba(245, 158, 11, 0.2) !important;
1776
+ background: linear-gradient(135deg, rgba(245, 158, 11, 0.03), rgba(245, 158, 11, 0.01)) !important;
1777
+ }
1778
+ .duplicate-alert::before {
1779
+ content: '';
1780
+ position: absolute;
1781
+ top: 0;
1782
+ left: 0;
1783
+ right: 0;
1784
+ height: 2px;
1785
+ background: linear-gradient(90deg, transparent, #F59E0B, transparent);
1786
+ }
1787
+
1788
+ /* ── Welcome Panel ── */
1789
+ .welcome-panel {
1790
+ position: relative;
1791
+ overflow: hidden;
1792
+ }
1793
+ .welcome-panel::before {
1794
+ content: '';
1795
+ position: absolute;
1796
+ top: -40px;
1797
+ right: -40px;
1798
+ width: 120px;
1799
+ height: 120px;
1800
+ background: radial-gradient(circle, rgba(245, 158, 11, 0.08), transparent 70%);
1801
+ border-radius: 50%;
1802
+ pointer-events: none;
1803
+ }
1804
+ .welcome-panel::after {
1805
+ content: '';
1806
+ position: absolute;
1807
+ bottom: -30px;
1808
+ left: -30px;
1809
+ width: 100px;
1810
+ height: 100px;
1811
+ background: radial-gradient(circle, rgba(20, 184, 166, 0.06), transparent 70%);
1812
+ border-radius: 50%;
1813
+ pointer-events: none;
1814
+ }
1815
+ .welcome-stat-btn {
1816
+ display: flex;
1817
+ align-items: center;
1818
+ gap: 8px;
1819
+ padding: 12px;
1820
+ border-radius: 10px;
1821
+ background: rgba(255, 255, 255, 0.03);
1822
+ border: 1px solid rgba(255, 255, 255, 0.04);
1823
+ text-align: left;
1824
+ cursor: pointer;
1825
+ transition: all 0.2s ease;
1826
+ color: inherit;
1827
+ width: 100%;
1828
+ font-family: inherit;
1829
+ }
1830
+ .welcome-stat-btn:hover {
1831
+ background: rgba(255, 255, 255, 0.06);
1832
+ border-color: rgba(255, 255, 255, 0.08);
1833
+ transform: translateY(-1px);
1834
+ }
1835
+ .welcome-stat-btn:active {
1836
+ transform: translateY(0);
1837
+ }
1838
+ .welcome-stat-icon {
1839
+ color: var(--muted-foreground);
1840
+ transition: color 0.2s ease;
1841
+ }
1842
+ .welcome-stat-btn:hover .welcome-stat-icon {
1843
+ color: #F59E0B;
1844
+ }
1845
+
1846
+ /* ── Settings Toggle Switch ── */
1847
+ .toggle-switch {
1848
+ position: relative;
1849
+ width: 44px;
1850
+ height: 24px;
1851
+ border-radius: 12px;
1852
+ background: #3f3f46;
1853
+ border: none;
1854
+ cursor: pointer;
1855
+ transition: background 0.2s ease;
1856
+ padding: 0;
1857
+ flex-shrink: 0;
1858
+ }
1859
+ .toggle-switch.active {
1860
+ background: #F59E0B;
1861
+ }
1862
+ .toggle-switch-knob {
1863
+ position: absolute;
1864
+ top: 2px;
1865
+ left: 2px;
1866
+ width: 20px;
1867
+ height: 20px;
1868
+ border-radius: 50%;
1869
+ background: white;
1870
+ transition: transform 0.2s ease;
1871
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
1872
+ }
1873
+ .toggle-switch.active .toggle-switch-knob {
1874
+ transform: translateX(20px);
1875
+ }
1876
+
1877
+ /* ── Card Depth Hover ── */
1878
+ .card-depth {
1879
+ transition: transform 0.25s ease, box-shadow 0.25s ease;
1880
+ }
1881
+ .card-depth:hover {
1882
+ transform: translateY(-2px);
1883
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.05);
1884
+ }
1885
+
1886
+ /* ── Enhanced Badge System ── */
1887
+ .badge-pill {
1888
+ display: inline-flex;
1889
+ align-items: center;
1890
+ gap: 4px;
1891
+ padding: 2px 10px;
1892
+ border-radius: 9999px;
1893
+ font-size: 11px;
1894
+ font-weight: 500;
1895
+ letter-spacing: 0.01em;
1896
+ transition: all 0.15s ease;
1897
+ }
1898
+ .badge-pill-dot {
1899
+ width: 6px;
1900
+ height: 6px;
1901
+ border-radius: 50%;
1902
+ flex-shrink: 0;
1903
+ }
1904
+ .badge-teal-enhanced {
1905
+ background: rgba(20, 184, 166, 0.1);
1906
+ color: #14B8A6;
1907
+ border: 1px solid rgba(20, 184, 166, 0.15);
1908
+ }
1909
+ .badge-amber-enhanced {
1910
+ background: rgba(245, 158, 11, 0.1);
1911
+ color: #F59E0B;
1912
+ border: 1px solid rgba(245, 158, 11, 0.15);
1913
+ }
1914
+ .badge-red-enhanced {
1915
+ background: rgba(239, 68, 68, 0.1);
1916
+ color: #EF4444;
1917
+ border: 1px solid rgba(239, 68, 68, 0.15);
1918
+ }
1919
+ .badge-green-enhanced {
1920
+ background: rgba(34, 197, 94, 0.1);
1921
+ color: #22C55E;
1922
+ border: 1px solid rgba(34, 197, 94, 0.15);
1923
+ }
1924
+
1925
+ /* ── Pulse Dot ── */
1926
+ .pulse-dot {
1927
+ position: relative;
1928
+ width: 8px;
1929
+ height: 8px;
1930
+ border-radius: 50%;
1931
+ background: #F59E0B;
1932
+ }
1933
+ .pulse-dot::after {
1934
+ content: '';
1935
+ position: absolute;
1936
+ inset: -3px;
1937
+ border-radius: 50%;
1938
+ border: 2px solid #F59E0B;
1939
+ animation: pulseDot 2s ease infinite;
1940
+ }
1941
+ @keyframes pulseDot {
1942
+ 0% { transform: scale(0.8); opacity: 0.8; }
1943
+ 50% { transform: scale(1.4); opacity: 0; }
1944
+ 100% { transform: scale(0.8); opacity: 0; }
1945
+ }
1946
+
1947
+ /* ── Teal Gradient Text ── */
1948
+ .gradient-text-teal {
1949
+ background: linear-gradient(135deg, #14B8A6, #5EEAD4);
1950
+ -webkit-background-clip: text;
1951
+ -webkit-text-fill-color: transparent;
1952
+ background-clip: text;
1953
+ }
1954
+
1955
+ /* ── Refined Scrollbar ── */
1956
+ .scrollbar-thin::-webkit-scrollbar {
1957
+ width: 6px;
1958
+ height: 6px;
1959
+ }
1960
+ .scrollbar-thin::-webkit-scrollbar-track {
1961
+ background: transparent;
1962
+ }
1963
+ .scrollbar-thin::-webkit-scrollbar-thumb {
1964
+ background: rgba(255, 255, 255, 0.08);
1965
+ border-radius: 3px;
1966
+ }
1967
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
1968
+ background: rgba(255, 255, 255, 0.15);
1969
+ }
1970
+
1971
+ /* ── Glass Card Hover Glow ── */
1972
+ .glass-card-glow {
1973
+ position: relative;
1974
+ transition: all 0.3s ease;
1975
+ }
1976
+ .glass-card-glow::after {
1977
+ content: '';
1978
+ position: absolute;
1979
+ inset: -1px;
1980
+ border-radius: inherit;
1981
+ background: linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(20, 184, 166, 0.1), transparent, transparent);
1982
+ opacity: 0;
1983
+ z-index: -1;
1984
+ transition: opacity 0.3s ease;
1985
+ filter: blur(8px);
1986
+ }
1987
+ .glass-card-glow:hover::after {
1988
+ opacity: 1;
1989
+ }
1990
+
1991
+ /* ── Light Mode: New Class Overrides ── */
1992
+ :root.light .command-palette {
1993
+ background: rgba(255, 255, 255, 0.95);
1994
+ border-color: rgba(0, 0, 0, 0.08);
1995
+ box-shadow: 0 25px 60px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.03);
1996
+ }
1997
+ :root.light .command-palette-input {
1998
+ border-bottom-color: rgba(0, 0, 0, 0.06);
1999
+ }
2000
+ :root.light .command-palette-item:hover {
2001
+ background: rgba(0, 0, 0, 0.04);
2002
+ }
2003
+ :root.light .duplicate-alert {
2004
+ background: linear-gradient(135deg, rgba(245, 158, 11, 0.05), rgba(245, 158, 11, 0.02)) !important;
2005
+ }
2006
+ :root.light .welcome-panel::before {
2007
+ background: radial-gradient(circle, rgba(245, 158, 11, 0.1), transparent 70%);
2008
+ }
2009
+ :root.light .welcome-panel::after {
2010
+ background: radial-gradient(circle, rgba(20, 184, 166, 0.08), transparent 70%);
2011
+ }
2012
+ :root.light .welcome-stat-btn {
2013
+ background: rgba(0, 0, 0, 0.02);
2014
+ border-color: rgba(0, 0, 0, 0.06);
2015
+ }
2016
+ :root.light .welcome-stat-btn:hover {
2017
+ background: rgba(0, 0, 0, 0.04);
2018
+ border-color: rgba(0, 0, 0, 0.1);
2019
+ }
2020
+ :root.light .card-depth:hover {
2021
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.04);
2022
+ }
2023
+ :root.light .toggle-switch {
2024
+ background: #d4d4d8;
2025
+ }
2026
+ :root.light .scrollbar-thin::-webkit-scrollbar-thumb {
2027
+ background: rgba(0, 0, 0, 0.1);
2028
+ }
2029
+ :root.light .scrollbar-thin::-webkit-scrollbar-thumb:hover {
2030
+ background: rgba(0, 0, 0, 0.2);
2031
+ }
2032
+ :root.light .sort-header-inactive {
2033
+ opacity: 0;
2034
+ }
2035
+ :root.light .sort-header:hover .sort-header-inactive {
2036
+ opacity: 0.2;
2037
+ }
2038
+ :root.light .glass-card-glow::after {
2039
+ background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(20, 184, 166, 0.15), transparent, transparent);
2040
+ }
src/app/layout.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Geist, Geist_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+ import { Toaster } from "sonner";
5
+ import { ThemeProvider } from 'next-themes';
6
+
7
+ const geistSans = Geist({
8
+ variable: "--font-geist-sans",
9
+ subsets: ["latin"],
10
+ });
11
+
12
+ const geistMono = Geist_Mono({
13
+ variable: "--font-geist-mono",
14
+ subsets: ["latin"],
15
+ });
16
+
17
+ export const metadata: Metadata = {
18
+ title: "OmniParse AI — Invoice Processing in Seconds",
19
+ description:
20
+ "AI-powered invoice processing. Upload PDFs or images and extract vendor, dates, amounts, and line items as structured data. Export to CSV, JSON, or connect via API.",
21
+ keywords: [
22
+ "invoice processing",
23
+ "OCR",
24
+ "AI extraction",
25
+ "automated accounting",
26
+ "document processing",
27
+ ],
28
+ icons: {
29
+ icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%23F59E0B'/><path d='M8 10h6v2H10v3h4v2h-4v3h4v2H8V10zm10 0h2l2 4 2-4h2v12h-2.5v-7.5L20 19h-.2l-1.3-2.5V22H16V10z' fill='%2309090B'/></svg>",
30
+ },
31
+ };
32
+
33
+ export default function RootLayout({
34
+ children,
35
+ }: Readonly<{
36
+ children: React.ReactNode;
37
+ }>) {
38
+ return (
39
+ <html lang="en" suppressHydrationWarning className="dark">
40
+ <body
41
+ className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground noise-overlay`}
42
+ >
43
+ <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
44
+ {children}
45
+ </ThemeProvider>
46
+ <Toaster
47
+ position="bottom-right"
48
+ toastOptions={{
49
+ style: {
50
+ background: "#1C1C1F",
51
+ border: "1px solid rgba(255,255,255,0.08)",
52
+ color: "#FAFAFA",
53
+ },
54
+ }}
55
+ />
56
+ </body>
57
+ </html>
58
+ );
59
+ }
src/app/page.tsx ADDED
The diff for this file is too large to render. See raw diff
 
src/components/ui/accordion.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AccordionPrimitive from "@radix-ui/react-accordion"
5
+ import { ChevronDownIcon } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function Accordion({
10
+ ...props
11
+ }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
12
+ return <AccordionPrimitive.Root data-slot="accordion" {...props} />
13
+ }
14
+
15
+ function AccordionItem({
16
+ className,
17
+ ...props
18
+ }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
19
+ return (
20
+ <AccordionPrimitive.Item
21
+ data-slot="accordion-item"
22
+ className={cn("border-b last:border-b-0", className)}
23
+ {...props}
24
+ />
25
+ )
26
+ }
27
+
28
+ function AccordionTrigger({
29
+ className,
30
+ children,
31
+ ...props
32
+ }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
33
+ return (
34
+ <AccordionPrimitive.Header className="flex">
35
+ <AccordionPrimitive.Trigger
36
+ data-slot="accordion-trigger"
37
+ className={cn(
38
+ "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",
39
+ className
40
+ )}
41
+ {...props}
42
+ >
43
+ {children}
44
+ <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
45
+ </AccordionPrimitive.Trigger>
46
+ </AccordionPrimitive.Header>
47
+ )
48
+ }
49
+
50
+ function AccordionContent({
51
+ className,
52
+ children,
53
+ ...props
54
+ }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
55
+ return (
56
+ <AccordionPrimitive.Content
57
+ data-slot="accordion-content"
58
+ className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
59
+ {...props}
60
+ >
61
+ <div className={cn("pt-0 pb-4", className)}>{children}</div>
62
+ </AccordionPrimitive.Content>
63
+ )
64
+ }
65
+
66
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
src/components/ui/alert-dialog.tsx ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5
+
6
+ import { cn } from "@/lib/utils"
7
+ import { buttonVariants } from "@/components/ui/button"
8
+
9
+ function AlertDialog({
10
+ ...props
11
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
12
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
13
+ }
14
+
15
+ function AlertDialogTrigger({
16
+ ...props
17
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
18
+ return (
19
+ <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
20
+ )
21
+ }
22
+
23
+ function AlertDialogPortal({
24
+ ...props
25
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
26
+ return (
27
+ <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
28
+ )
29
+ }
30
+
31
+ function AlertDialogOverlay({
32
+ className,
33
+ ...props
34
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
35
+ return (
36
+ <AlertDialogPrimitive.Overlay
37
+ data-slot="alert-dialog-overlay"
38
+ className={cn(
39
+ "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",
40
+ className
41
+ )}
42
+ {...props}
43
+ />
44
+ )
45
+ }
46
+
47
+ function AlertDialogContent({
48
+ className,
49
+ ...props
50
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
51
+ return (
52
+ <AlertDialogPortal>
53
+ <AlertDialogOverlay />
54
+ <AlertDialogPrimitive.Content
55
+ data-slot="alert-dialog-content"
56
+ className={cn(
57
+ "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",
58
+ className
59
+ )}
60
+ {...props}
61
+ />
62
+ </AlertDialogPortal>
63
+ )
64
+ }
65
+
66
+ function AlertDialogHeader({
67
+ className,
68
+ ...props
69
+ }: React.ComponentProps<"div">) {
70
+ return (
71
+ <div
72
+ data-slot="alert-dialog-header"
73
+ className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
74
+ {...props}
75
+ />
76
+ )
77
+ }
78
+
79
+ function AlertDialogFooter({
80
+ className,
81
+ ...props
82
+ }: React.ComponentProps<"div">) {
83
+ return (
84
+ <div
85
+ data-slot="alert-dialog-footer"
86
+ className={cn(
87
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
88
+ className
89
+ )}
90
+ {...props}
91
+ />
92
+ )
93
+ }
94
+
95
+ function AlertDialogTitle({
96
+ className,
97
+ ...props
98
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
99
+ return (
100
+ <AlertDialogPrimitive.Title
101
+ data-slot="alert-dialog-title"
102
+ className={cn("text-lg font-semibold", className)}
103
+ {...props}
104
+ />
105
+ )
106
+ }
107
+
108
+ function AlertDialogDescription({
109
+ className,
110
+ ...props
111
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
112
+ return (
113
+ <AlertDialogPrimitive.Description
114
+ data-slot="alert-dialog-description"
115
+ className={cn("text-muted-foreground text-sm", className)}
116
+ {...props}
117
+ />
118
+ )
119
+ }
120
+
121
+ function AlertDialogAction({
122
+ className,
123
+ ...props
124
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
125
+ return (
126
+ <AlertDialogPrimitive.Action
127
+ className={cn(buttonVariants(), className)}
128
+ {...props}
129
+ />
130
+ )
131
+ }
132
+
133
+ function AlertDialogCancel({
134
+ className,
135
+ ...props
136
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
137
+ return (
138
+ <AlertDialogPrimitive.Cancel
139
+ className={cn(buttonVariants({ variant: "outline" }), className)}
140
+ {...props}
141
+ />
142
+ )
143
+ }
144
+
145
+ export {
146
+ AlertDialog,
147
+ AlertDialogPortal,
148
+ AlertDialogOverlay,
149
+ AlertDialogTrigger,
150
+ AlertDialogContent,
151
+ AlertDialogHeader,
152
+ AlertDialogFooter,
153
+ AlertDialogTitle,
154
+ AlertDialogDescription,
155
+ AlertDialogAction,
156
+ AlertDialogCancel,
157
+ }
src/components/ui/alert.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const alertVariants = cva(
7
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-card text-card-foreground",
12
+ destructive:
13
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ variant: "default",
18
+ },
19
+ }
20
+ )
21
+
22
+ function Alert({
23
+ className,
24
+ variant,
25
+ ...props
26
+ }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
27
+ return (
28
+ <div
29
+ data-slot="alert"
30
+ role="alert"
31
+ className={cn(alertVariants({ variant }), className)}
32
+ {...props}
33
+ />
34
+ )
35
+ }
36
+
37
+ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
38
+ return (
39
+ <div
40
+ data-slot="alert-title"
41
+ className={cn(
42
+ "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
43
+ className
44
+ )}
45
+ {...props}
46
+ />
47
+ )
48
+ }
49
+
50
+ function AlertDescription({
51
+ className,
52
+ ...props
53
+ }: React.ComponentProps<"div">) {
54
+ return (
55
+ <div
56
+ data-slot="alert-description"
57
+ className={cn(
58
+ "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
59
+ className
60
+ )}
61
+ {...props}
62
+ />
63
+ )
64
+ }
65
+
66
+ export { Alert, AlertTitle, AlertDescription }
src/components/ui/aspect-ratio.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
4
+
5
+ function AspectRatio({
6
+ ...props
7
+ }: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
8
+ return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
9
+ }
10
+
11
+ export { AspectRatio }
src/components/ui/avatar.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AvatarPrimitive from "@radix-ui/react-avatar"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Avatar({
9
+ className,
10
+ ...props
11
+ }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
12
+ return (
13
+ <AvatarPrimitive.Root
14
+ data-slot="avatar"
15
+ className={cn(
16
+ "relative flex size-8 shrink-0 overflow-hidden rounded-full",
17
+ className
18
+ )}
19
+ {...props}
20
+ />
21
+ )
22
+ }
23
+
24
+ function AvatarImage({
25
+ className,
26
+ ...props
27
+ }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
28
+ return (
29
+ <AvatarPrimitive.Image
30
+ data-slot="avatar-image"
31
+ className={cn("aspect-square size-full", className)}
32
+ {...props}
33
+ />
34
+ )
35
+ }
36
+
37
+ function AvatarFallback({
38
+ className,
39
+ ...props
40
+ }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
41
+ return (
42
+ <AvatarPrimitive.Fallback
43
+ data-slot="avatar-fallback"
44
+ className={cn(
45
+ "bg-muted flex size-full items-center justify-center rounded-full",
46
+ className
47
+ )}
48
+ {...props}
49
+ />
50
+ )
51
+ }
52
+
53
+ export { Avatar, AvatarImage, AvatarFallback }
src/components/ui/badge.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const badgeVariants = cva(
8
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
14
+ secondary:
15
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
16
+ destructive:
17
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
18
+ outline:
19
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ variant: "default",
24
+ },
25
+ }
26
+ )
27
+
28
+ function Badge({
29
+ className,
30
+ variant,
31
+ asChild = false,
32
+ ...props
33
+ }: React.ComponentProps<"span"> &
34
+ VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
35
+ const Comp = asChild ? Slot : "span"
36
+
37
+ return (
38
+ <Comp
39
+ data-slot="badge"
40
+ className={cn(badgeVariants({ variant }), className)}
41
+ {...props}
42
+ />
43
+ )
44
+ }
45
+
46
+ export { Badge, badgeVariants }
src/components/ui/breadcrumb.tsx ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { ChevronRight, MoreHorizontal } from "lucide-react"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
8
+ return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
9
+ }
10
+
11
+ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
12
+ return (
13
+ <ol
14
+ data-slot="breadcrumb-list"
15
+ className={cn(
16
+ "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
17
+ className
18
+ )}
19
+ {...props}
20
+ />
21
+ )
22
+ }
23
+
24
+ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
25
+ return (
26
+ <li
27
+ data-slot="breadcrumb-item"
28
+ className={cn("inline-flex items-center gap-1.5", className)}
29
+ {...props}
30
+ />
31
+ )
32
+ }
33
+
34
+ function BreadcrumbLink({
35
+ asChild,
36
+ className,
37
+ ...props
38
+ }: React.ComponentProps<"a"> & {
39
+ asChild?: boolean
40
+ }) {
41
+ const Comp = asChild ? Slot : "a"
42
+
43
+ return (
44
+ <Comp
45
+ data-slot="breadcrumb-link"
46
+ className={cn("hover:text-foreground transition-colors", className)}
47
+ {...props}
48
+ />
49
+ )
50
+ }
51
+
52
+ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
53
+ return (
54
+ <span
55
+ data-slot="breadcrumb-page"
56
+ role="link"
57
+ aria-disabled="true"
58
+ aria-current="page"
59
+ className={cn("text-foreground font-normal", className)}
60
+ {...props}
61
+ />
62
+ )
63
+ }
64
+
65
+ function BreadcrumbSeparator({
66
+ children,
67
+ className,
68
+ ...props
69
+ }: React.ComponentProps<"li">) {
70
+ return (
71
+ <li
72
+ data-slot="breadcrumb-separator"
73
+ role="presentation"
74
+ aria-hidden="true"
75
+ className={cn("[&>svg]:size-3.5", className)}
76
+ {...props}
77
+ >
78
+ {children ?? <ChevronRight />}
79
+ </li>
80
+ )
81
+ }
82
+
83
+ function BreadcrumbEllipsis({
84
+ className,
85
+ ...props
86
+ }: React.ComponentProps<"span">) {
87
+ return (
88
+ <span
89
+ data-slot="breadcrumb-ellipsis"
90
+ role="presentation"
91
+ aria-hidden="true"
92
+ className={cn("flex size-9 items-center justify-center", className)}
93
+ {...props}
94
+ >
95
+ <MoreHorizontal className="size-4" />
96
+ <span className="sr-only">More</span>
97
+ </span>
98
+ )
99
+ }
100
+
101
+ export {
102
+ Breadcrumb,
103
+ BreadcrumbList,
104
+ BreadcrumbItem,
105
+ BreadcrumbLink,
106
+ BreadcrumbPage,
107
+ BreadcrumbSeparator,
108
+ BreadcrumbEllipsis,
109
+ }
src/components/ui/button.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14
+ destructive:
15
+ "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16
+ outline:
17
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18
+ secondary:
19
+ "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20
+ ghost:
21
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22
+ link: "text-primary underline-offset-4 hover:underline",
23
+ },
24
+ size: {
25
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
26
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28
+ icon: "size-9",
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ variant: "default",
33
+ size: "default",
34
+ },
35
+ }
36
+ )
37
+
38
+ function Button({
39
+ className,
40
+ variant,
41
+ size,
42
+ asChild = false,
43
+ ...props
44
+ }: React.ComponentProps<"button"> &
45
+ VariantProps<typeof buttonVariants> & {
46
+ asChild?: boolean
47
+ }) {
48
+ const Comp = asChild ? Slot : "button"
49
+
50
+ return (
51
+ <Comp
52
+ data-slot="button"
53
+ className={cn(buttonVariants({ variant, size, className }))}
54
+ {...props}
55
+ />
56
+ )
57
+ }
58
+
59
+ export { Button, buttonVariants }
src/components/ui/calendar.tsx ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ ChevronDownIcon,
6
+ ChevronLeftIcon,
7
+ ChevronRightIcon,
8
+ } from "lucide-react"
9
+ import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
10
+
11
+ import { cn } from "@/lib/utils"
12
+ import { Button, buttonVariants } from "@/components/ui/button"
13
+
14
+ function Calendar({
15
+ className,
16
+ classNames,
17
+ showOutsideDays = true,
18
+ captionLayout = "label",
19
+ buttonVariant = "ghost",
20
+ formatters,
21
+ components,
22
+ ...props
23
+ }: React.ComponentProps<typeof DayPicker> & {
24
+ buttonVariant?: React.ComponentProps<typeof Button>["variant"]
25
+ }) {
26
+ const defaultClassNames = getDefaultClassNames()
27
+
28
+ return (
29
+ <DayPicker
30
+ showOutsideDays={showOutsideDays}
31
+ className={cn(
32
+ "bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
33
+ String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
34
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
35
+ className
36
+ )}
37
+ captionLayout={captionLayout}
38
+ formatters={{
39
+ formatMonthDropdown: (date) =>
40
+ date.toLocaleString("default", { month: "short" }),
41
+ ...formatters,
42
+ }}
43
+ classNames={{
44
+ root: cn("w-fit", defaultClassNames.root),
45
+ months: cn(
46
+ "flex gap-4 flex-col md:flex-row relative",
47
+ defaultClassNames.months
48
+ ),
49
+ month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
50
+ nav: cn(
51
+ "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
52
+ defaultClassNames.nav
53
+ ),
54
+ button_previous: cn(
55
+ buttonVariants({ variant: buttonVariant }),
56
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
57
+ defaultClassNames.button_previous
58
+ ),
59
+ button_next: cn(
60
+ buttonVariants({ variant: buttonVariant }),
61
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
62
+ defaultClassNames.button_next
63
+ ),
64
+ month_caption: cn(
65
+ "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
66
+ defaultClassNames.month_caption
67
+ ),
68
+ dropdowns: cn(
69
+ "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
70
+ defaultClassNames.dropdowns
71
+ ),
72
+ dropdown_root: cn(
73
+ "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
74
+ defaultClassNames.dropdown_root
75
+ ),
76
+ dropdown: cn(
77
+ "absolute bg-popover inset-0 opacity-0",
78
+ defaultClassNames.dropdown
79
+ ),
80
+ caption_label: cn(
81
+ "select-none font-medium",
82
+ captionLayout === "label"
83
+ ? "text-sm"
84
+ : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
85
+ defaultClassNames.caption_label
86
+ ),
87
+ table: "w-full border-collapse",
88
+ weekdays: cn("flex", defaultClassNames.weekdays),
89
+ weekday: cn(
90
+ "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
91
+ defaultClassNames.weekday
92
+ ),
93
+ week: cn("flex w-full mt-2", defaultClassNames.week),
94
+ week_number_header: cn(
95
+ "select-none w-(--cell-size)",
96
+ defaultClassNames.week_number_header
97
+ ),
98
+ week_number: cn(
99
+ "text-[0.8rem] select-none text-muted-foreground",
100
+ defaultClassNames.week_number
101
+ ),
102
+ day: cn(
103
+ "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
104
+ defaultClassNames.day
105
+ ),
106
+ range_start: cn(
107
+ "rounded-l-md bg-accent",
108
+ defaultClassNames.range_start
109
+ ),
110
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
111
+ range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
112
+ today: cn(
113
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
114
+ defaultClassNames.today
115
+ ),
116
+ outside: cn(
117
+ "text-muted-foreground aria-selected:text-muted-foreground",
118
+ defaultClassNames.outside
119
+ ),
120
+ disabled: cn(
121
+ "text-muted-foreground opacity-50",
122
+ defaultClassNames.disabled
123
+ ),
124
+ hidden: cn("invisible", defaultClassNames.hidden),
125
+ ...classNames,
126
+ }}
127
+ components={{
128
+ Root: ({ className, rootRef, ...props }) => {
129
+ return (
130
+ <div
131
+ data-slot="calendar"
132
+ ref={rootRef}
133
+ className={cn(className)}
134
+ {...props}
135
+ />
136
+ )
137
+ },
138
+ Chevron: ({ className, orientation, ...props }) => {
139
+ if (orientation === "left") {
140
+ return (
141
+ <ChevronLeftIcon className={cn("size-4", className)} {...props} />
142
+ )
143
+ }
144
+
145
+ if (orientation === "right") {
146
+ return (
147
+ <ChevronRightIcon
148
+ className={cn("size-4", className)}
149
+ {...props}
150
+ />
151
+ )
152
+ }
153
+
154
+ return (
155
+ <ChevronDownIcon className={cn("size-4", className)} {...props} />
156
+ )
157
+ },
158
+ DayButton: CalendarDayButton,
159
+ WeekNumber: ({ children, ...props }) => {
160
+ return (
161
+ <td {...props}>
162
+ <div className="flex size-(--cell-size) items-center justify-center text-center">
163
+ {children}
164
+ </div>
165
+ </td>
166
+ )
167
+ },
168
+ ...components,
169
+ }}
170
+ {...props}
171
+ />
172
+ )
173
+ }
174
+
175
+ function CalendarDayButton({
176
+ className,
177
+ day,
178
+ modifiers,
179
+ ...props
180
+ }: React.ComponentProps<typeof DayButton>) {
181
+ const defaultClassNames = getDefaultClassNames()
182
+
183
+ const ref = React.useRef<HTMLButtonElement>(null)
184
+ React.useEffect(() => {
185
+ if (modifiers.focused) ref.current?.focus()
186
+ }, [modifiers.focused])
187
+
188
+ return (
189
+ <Button
190
+ ref={ref}
191
+ variant="ghost"
192
+ size="icon"
193
+ data-day={day.date.toLocaleDateString()}
194
+ data-selected-single={
195
+ modifiers.selected &&
196
+ !modifiers.range_start &&
197
+ !modifiers.range_end &&
198
+ !modifiers.range_middle
199
+ }
200
+ data-range-start={modifiers.range_start}
201
+ data-range-end={modifiers.range_end}
202
+ data-range-middle={modifiers.range_middle}
203
+ className={cn(
204
+ "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
205
+ defaultClassNames.day,
206
+ className
207
+ )}
208
+ {...props}
209
+ />
210
+ )
211
+ }
212
+
213
+ export { Calendar, CalendarDayButton }
src/components/ui/card.tsx ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Card({ className, ...props }: React.ComponentProps<"div">) {
6
+ return (
7
+ <div
8
+ data-slot="card"
9
+ className={cn(
10
+ "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
11
+ className
12
+ )}
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
19
+ return (
20
+ <div
21
+ data-slot="card-header"
22
+ className={cn(
23
+ "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
24
+ className
25
+ )}
26
+ {...props}
27
+ />
28
+ )
29
+ }
30
+
31
+ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
32
+ return (
33
+ <div
34
+ data-slot="card-title"
35
+ className={cn("leading-none font-semibold", className)}
36
+ {...props}
37
+ />
38
+ )
39
+ }
40
+
41
+ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
42
+ return (
43
+ <div
44
+ data-slot="card-description"
45
+ className={cn("text-muted-foreground text-sm", className)}
46
+ {...props}
47
+ />
48
+ )
49
+ }
50
+
51
+ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52
+ return (
53
+ <div
54
+ data-slot="card-action"
55
+ className={cn(
56
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
57
+ className
58
+ )}
59
+ {...props}
60
+ />
61
+ )
62
+ }
63
+
64
+ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
65
+ return (
66
+ <div
67
+ data-slot="card-content"
68
+ className={cn("px-6", className)}
69
+ {...props}
70
+ />
71
+ )
72
+ }
73
+
74
+ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
75
+ return (
76
+ <div
77
+ data-slot="card-footer"
78
+ className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
79
+ {...props}
80
+ />
81
+ )
82
+ }
83
+
84
+ export {
85
+ Card,
86
+ CardHeader,
87
+ CardFooter,
88
+ CardTitle,
89
+ CardAction,
90
+ CardDescription,
91
+ CardContent,
92
+ }
src/components/ui/carousel.tsx ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import useEmblaCarousel, {
5
+ type UseEmblaCarouselType,
6
+ } from "embla-carousel-react"
7
+ import { ArrowLeft, ArrowRight } from "lucide-react"
8
+
9
+ import { cn } from "@/lib/utils"
10
+ import { Button } from "@/components/ui/button"
11
+
12
+ type CarouselApi = UseEmblaCarouselType[1]
13
+ type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
14
+ type CarouselOptions = UseCarouselParameters[0]
15
+ type CarouselPlugin = UseCarouselParameters[1]
16
+
17
+ type CarouselProps = {
18
+ opts?: CarouselOptions
19
+ plugins?: CarouselPlugin
20
+ orientation?: "horizontal" | "vertical"
21
+ setApi?: (api: CarouselApi) => void
22
+ }
23
+
24
+ type CarouselContextProps = {
25
+ carouselRef: ReturnType<typeof useEmblaCarousel>[0]
26
+ api: ReturnType<typeof useEmblaCarousel>[1]
27
+ scrollPrev: () => void
28
+ scrollNext: () => void
29
+ canScrollPrev: boolean
30
+ canScrollNext: boolean
31
+ } & CarouselProps
32
+
33
+ const CarouselContext = React.createContext<CarouselContextProps | null>(null)
34
+
35
+ function useCarousel() {
36
+ const context = React.useContext(CarouselContext)
37
+
38
+ if (!context) {
39
+ throw new Error("useCarousel must be used within a <Carousel />")
40
+ }
41
+
42
+ return context
43
+ }
44
+
45
+ function Carousel({
46
+ orientation = "horizontal",
47
+ opts,
48
+ setApi,
49
+ plugins,
50
+ className,
51
+ children,
52
+ ...props
53
+ }: React.ComponentProps<"div"> & CarouselProps) {
54
+ const [carouselRef, api] = useEmblaCarousel(
55
+ {
56
+ ...opts,
57
+ axis: orientation === "horizontal" ? "x" : "y",
58
+ },
59
+ plugins
60
+ )
61
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
62
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
63
+
64
+ const onSelect = React.useCallback((api: CarouselApi) => {
65
+ if (!api) return
66
+ setCanScrollPrev(api.canScrollPrev())
67
+ setCanScrollNext(api.canScrollNext())
68
+ }, [])
69
+
70
+ const scrollPrev = React.useCallback(() => {
71
+ api?.scrollPrev()
72
+ }, [api])
73
+
74
+ const scrollNext = React.useCallback(() => {
75
+ api?.scrollNext()
76
+ }, [api])
77
+
78
+ const handleKeyDown = React.useCallback(
79
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
80
+ if (event.key === "ArrowLeft") {
81
+ event.preventDefault()
82
+ scrollPrev()
83
+ } else if (event.key === "ArrowRight") {
84
+ event.preventDefault()
85
+ scrollNext()
86
+ }
87
+ },
88
+ [scrollPrev, scrollNext]
89
+ )
90
+
91
+ React.useEffect(() => {
92
+ if (!api || !setApi) return
93
+ setApi(api)
94
+ }, [api, setApi])
95
+
96
+ React.useEffect(() => {
97
+ if (!api) return
98
+ onSelect(api)
99
+ api.on("reInit", onSelect)
100
+ api.on("select", onSelect)
101
+
102
+ return () => {
103
+ api?.off("select", onSelect)
104
+ }
105
+ }, [api, onSelect])
106
+
107
+ return (
108
+ <CarouselContext.Provider
109
+ value={{
110
+ carouselRef,
111
+ api: api,
112
+ opts,
113
+ orientation:
114
+ orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
115
+ scrollPrev,
116
+ scrollNext,
117
+ canScrollPrev,
118
+ canScrollNext,
119
+ }}
120
+ >
121
+ <div
122
+ onKeyDownCapture={handleKeyDown}
123
+ className={cn("relative", className)}
124
+ role="region"
125
+ aria-roledescription="carousel"
126
+ data-slot="carousel"
127
+ {...props}
128
+ >
129
+ {children}
130
+ </div>
131
+ </CarouselContext.Provider>
132
+ )
133
+ }
134
+
135
+ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
136
+ const { carouselRef, orientation } = useCarousel()
137
+
138
+ return (
139
+ <div
140
+ ref={carouselRef}
141
+ className="overflow-hidden"
142
+ data-slot="carousel-content"
143
+ >
144
+ <div
145
+ className={cn(
146
+ "flex",
147
+ orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
148
+ className
149
+ )}
150
+ {...props}
151
+ />
152
+ </div>
153
+ )
154
+ }
155
+
156
+ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
157
+ const { orientation } = useCarousel()
158
+
159
+ return (
160
+ <div
161
+ role="group"
162
+ aria-roledescription="slide"
163
+ data-slot="carousel-item"
164
+ className={cn(
165
+ "min-w-0 shrink-0 grow-0 basis-full",
166
+ orientation === "horizontal" ? "pl-4" : "pt-4",
167
+ className
168
+ )}
169
+ {...props}
170
+ />
171
+ )
172
+ }
173
+
174
+ function CarouselPrevious({
175
+ className,
176
+ variant = "outline",
177
+ size = "icon",
178
+ ...props
179
+ }: React.ComponentProps<typeof Button>) {
180
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
181
+
182
+ return (
183
+ <Button
184
+ data-slot="carousel-previous"
185
+ variant={variant}
186
+ size={size}
187
+ className={cn(
188
+ "absolute size-8 rounded-full",
189
+ orientation === "horizontal"
190
+ ? "top-1/2 -left-12 -translate-y-1/2"
191
+ : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
192
+ className
193
+ )}
194
+ disabled={!canScrollPrev}
195
+ onClick={scrollPrev}
196
+ {...props}
197
+ >
198
+ <ArrowLeft />
199
+ <span className="sr-only">Previous slide</span>
200
+ </Button>
201
+ )
202
+ }
203
+
204
+ function CarouselNext({
205
+ className,
206
+ variant = "outline",
207
+ size = "icon",
208
+ ...props
209
+ }: React.ComponentProps<typeof Button>) {
210
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
211
+
212
+ return (
213
+ <Button
214
+ data-slot="carousel-next"
215
+ variant={variant}
216
+ size={size}
217
+ className={cn(
218
+ "absolute size-8 rounded-full",
219
+ orientation === "horizontal"
220
+ ? "top-1/2 -right-12 -translate-y-1/2"
221
+ : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
222
+ className
223
+ )}
224
+ disabled={!canScrollNext}
225
+ onClick={scrollNext}
226
+ {...props}
227
+ >
228
+ <ArrowRight />
229
+ <span className="sr-only">Next slide</span>
230
+ </Button>
231
+ )
232
+ }
233
+
234
+ export {
235
+ type CarouselApi,
236
+ Carousel,
237
+ CarouselContent,
238
+ CarouselItem,
239
+ CarouselPrevious,
240
+ CarouselNext,
241
+ }
src/components/ui/chart.tsx ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as RechartsPrimitive from "recharts"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ // Format: { THEME_NAME: CSS_SELECTOR }
9
+ const THEMES = { light: "", dark: ".dark" } as const
10
+
11
+ export type ChartConfig = {
12
+ [k in string]: {
13
+ label?: React.ReactNode
14
+ icon?: React.ComponentType
15
+ } & (
16
+ | { color?: string; theme?: never }
17
+ | { color?: never; theme: Record<keyof typeof THEMES, string> }
18
+ )
19
+ }
20
+
21
+ type ChartContextProps = {
22
+ config: ChartConfig
23
+ }
24
+
25
+ const ChartContext = React.createContext<ChartContextProps | null>(null)
26
+
27
+ function useChart() {
28
+ const context = React.useContext(ChartContext)
29
+
30
+ if (!context) {
31
+ throw new Error("useChart must be used within a <ChartContainer />")
32
+ }
33
+
34
+ return context
35
+ }
36
+
37
+ function ChartContainer({
38
+ id,
39
+ className,
40
+ children,
41
+ config,
42
+ ...props
43
+ }: React.ComponentProps<"div"> & {
44
+ config: ChartConfig
45
+ children: React.ComponentProps<
46
+ typeof RechartsPrimitive.ResponsiveContainer
47
+ >["children"]
48
+ }) {
49
+ const uniqueId = React.useId()
50
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
51
+
52
+ return (
53
+ <ChartContext.Provider value={{ config }}>
54
+ <div
55
+ data-slot="chart"
56
+ data-chart={chartId}
57
+ className={cn(
58
+ "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
59
+ className
60
+ )}
61
+ {...props}
62
+ >
63
+ <ChartStyle id={chartId} config={config} />
64
+ <RechartsPrimitive.ResponsiveContainer>
65
+ {children}
66
+ </RechartsPrimitive.ResponsiveContainer>
67
+ </div>
68
+ </ChartContext.Provider>
69
+ )
70
+ }
71
+
72
+ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
73
+ const colorConfig = Object.entries(config).filter(
74
+ ([, config]) => config.theme || config.color
75
+ )
76
+
77
+ if (!colorConfig.length) {
78
+ return null
79
+ }
80
+
81
+ return (
82
+ <style
83
+ dangerouslySetInnerHTML={{
84
+ __html: Object.entries(THEMES)
85
+ .map(
86
+ ([theme, prefix]) => `
87
+ ${prefix} [data-chart=${id}] {
88
+ ${colorConfig
89
+ .map(([key, itemConfig]) => {
90
+ const color =
91
+ itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
92
+ itemConfig.color
93
+ return color ? ` --color-${key}: ${color};` : null
94
+ })
95
+ .join("\n")}
96
+ }
97
+ `
98
+ )
99
+ .join("\n"),
100
+ }}
101
+ />
102
+ )
103
+ }
104
+
105
+ const ChartTooltip = RechartsPrimitive.Tooltip
106
+
107
+ function ChartTooltipContent({
108
+ active,
109
+ payload,
110
+ className,
111
+ indicator = "dot",
112
+ hideLabel = false,
113
+ hideIndicator = false,
114
+ label,
115
+ labelFormatter,
116
+ labelClassName,
117
+ formatter,
118
+ color,
119
+ nameKey,
120
+ labelKey,
121
+ }: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
122
+ React.ComponentProps<"div"> & {
123
+ hideLabel?: boolean
124
+ hideIndicator?: boolean
125
+ indicator?: "line" | "dot" | "dashed"
126
+ nameKey?: string
127
+ labelKey?: string
128
+ }) {
129
+ const { config } = useChart()
130
+
131
+ const tooltipLabel = React.useMemo(() => {
132
+ if (hideLabel || !payload?.length) {
133
+ return null
134
+ }
135
+
136
+ const [item] = payload
137
+ const key = `${labelKey || item?.dataKey || item?.name || "value"}`
138
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
139
+ const value =
140
+ !labelKey && typeof label === "string"
141
+ ? config[label as keyof typeof config]?.label || label
142
+ : itemConfig?.label
143
+
144
+ if (labelFormatter) {
145
+ return (
146
+ <div className={cn("font-medium", labelClassName)}>
147
+ {labelFormatter(value, payload)}
148
+ </div>
149
+ )
150
+ }
151
+
152
+ if (!value) {
153
+ return null
154
+ }
155
+
156
+ return <div className={cn("font-medium", labelClassName)}>{value}</div>
157
+ }, [
158
+ label,
159
+ labelFormatter,
160
+ payload,
161
+ hideLabel,
162
+ labelClassName,
163
+ config,
164
+ labelKey,
165
+ ])
166
+
167
+ if (!active || !payload?.length) {
168
+ return null
169
+ }
170
+
171
+ const nestLabel = payload.length === 1 && indicator !== "dot"
172
+
173
+ return (
174
+ <div
175
+ className={cn(
176
+ "border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
177
+ className
178
+ )}
179
+ >
180
+ {!nestLabel ? tooltipLabel : null}
181
+ <div className="grid gap-1.5">
182
+ {payload.map((item, index) => {
183
+ const key = `${nameKey || item.name || item.dataKey || "value"}`
184
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
185
+ const indicatorColor = color || item.payload.fill || item.color
186
+
187
+ return (
188
+ <div
189
+ key={item.dataKey}
190
+ className={cn(
191
+ "[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
192
+ indicator === "dot" && "items-center"
193
+ )}
194
+ >
195
+ {formatter && item?.value !== undefined && item.name ? (
196
+ formatter(item.value, item.name, item, index, item.payload)
197
+ ) : (
198
+ <>
199
+ {itemConfig?.icon ? (
200
+ <itemConfig.icon />
201
+ ) : (
202
+ !hideIndicator && (
203
+ <div
204
+ className={cn(
205
+ "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
206
+ {
207
+ "h-2.5 w-2.5": indicator === "dot",
208
+ "w-1": indicator === "line",
209
+ "w-0 border-[1.5px] border-dashed bg-transparent":
210
+ indicator === "dashed",
211
+ "my-0.5": nestLabel && indicator === "dashed",
212
+ }
213
+ )}
214
+ style={
215
+ {
216
+ "--color-bg": indicatorColor,
217
+ "--color-border": indicatorColor,
218
+ } as React.CSSProperties
219
+ }
220
+ />
221
+ )
222
+ )}
223
+ <div
224
+ className={cn(
225
+ "flex flex-1 justify-between leading-none",
226
+ nestLabel ? "items-end" : "items-center"
227
+ )}
228
+ >
229
+ <div className="grid gap-1.5">
230
+ {nestLabel ? tooltipLabel : null}
231
+ <span className="text-muted-foreground">
232
+ {itemConfig?.label || item.name}
233
+ </span>
234
+ </div>
235
+ {item.value && (
236
+ <span className="text-foreground font-mono font-medium tabular-nums">
237
+ {item.value.toLocaleString()}
238
+ </span>
239
+ )}
240
+ </div>
241
+ </>
242
+ )}
243
+ </div>
244
+ )
245
+ })}
246
+ </div>
247
+ </div>
248
+ )
249
+ }
250
+
251
+ const ChartLegend = RechartsPrimitive.Legend
252
+
253
+ function ChartLegendContent({
254
+ className,
255
+ hideIcon = false,
256
+ payload,
257
+ verticalAlign = "bottom",
258
+ nameKey,
259
+ }: React.ComponentProps<"div"> &
260
+ Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
261
+ hideIcon?: boolean
262
+ nameKey?: string
263
+ }) {
264
+ const { config } = useChart()
265
+
266
+ if (!payload?.length) {
267
+ return null
268
+ }
269
+
270
+ return (
271
+ <div
272
+ className={cn(
273
+ "flex items-center justify-center gap-4",
274
+ verticalAlign === "top" ? "pb-3" : "pt-3",
275
+ className
276
+ )}
277
+ >
278
+ {payload.map((item) => {
279
+ const key = `${nameKey || item.dataKey || "value"}`
280
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
281
+
282
+ return (
283
+ <div
284
+ key={item.value}
285
+ className={cn(
286
+ "[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
287
+ )}
288
+ >
289
+ {itemConfig?.icon && !hideIcon ? (
290
+ <itemConfig.icon />
291
+ ) : (
292
+ <div
293
+ className="h-2 w-2 shrink-0 rounded-[2px]"
294
+ style={{
295
+ backgroundColor: item.color,
296
+ }}
297
+ />
298
+ )}
299
+ {itemConfig?.label}
300
+ </div>
301
+ )
302
+ })}
303
+ </div>
304
+ )
305
+ }
306
+
307
+ // Helper to extract item config from a payload.
308
+ function getPayloadConfigFromPayload(
309
+ config: ChartConfig,
310
+ payload: unknown,
311
+ key: string
312
+ ) {
313
+ if (typeof payload !== "object" || payload === null) {
314
+ return undefined
315
+ }
316
+
317
+ const payloadPayload =
318
+ "payload" in payload &&
319
+ typeof payload.payload === "object" &&
320
+ payload.payload !== null
321
+ ? payload.payload
322
+ : undefined
323
+
324
+ let configLabelKey: string = key
325
+
326
+ if (
327
+ key in payload &&
328
+ typeof payload[key as keyof typeof payload] === "string"
329
+ ) {
330
+ configLabelKey = payload[key as keyof typeof payload] as string
331
+ } else if (
332
+ payloadPayload &&
333
+ key in payloadPayload &&
334
+ typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
335
+ ) {
336
+ configLabelKey = payloadPayload[
337
+ key as keyof typeof payloadPayload
338
+ ] as string
339
+ }
340
+
341
+ return configLabelKey in config
342
+ ? config[configLabelKey]
343
+ : config[key as keyof typeof config]
344
+ }
345
+
346
+ export {
347
+ ChartContainer,
348
+ ChartTooltip,
349
+ ChartTooltipContent,
350
+ ChartLegend,
351
+ ChartLegendContent,
352
+ ChartStyle,
353
+ }
src/components/ui/checkbox.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5
+ import { CheckIcon } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function Checkbox({
10
+ className,
11
+ ...props
12
+ }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
13
+ return (
14
+ <CheckboxPrimitive.Root
15
+ data-slot="checkbox"
16
+ className={cn(
17
+ "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
18
+ className
19
+ )}
20
+ {...props}
21
+ >
22
+ <CheckboxPrimitive.Indicator
23
+ data-slot="checkbox-indicator"
24
+ className="flex items-center justify-center text-current transition-none"
25
+ >
26
+ <CheckIcon className="size-3.5" />
27
+ </CheckboxPrimitive.Indicator>
28
+ </CheckboxPrimitive.Root>
29
+ )
30
+ }
31
+
32
+ export { Checkbox }
src/components/ui/collapsible.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
4
+
5
+ function Collapsible({
6
+ ...props
7
+ }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
8
+ return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
9
+ }
10
+
11
+ function CollapsibleTrigger({
12
+ ...props
13
+ }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
14
+ return (
15
+ <CollapsiblePrimitive.CollapsibleTrigger
16
+ data-slot="collapsible-trigger"
17
+ {...props}
18
+ />
19
+ )
20
+ }
21
+
22
+ function CollapsibleContent({
23
+ ...props
24
+ }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
25
+ return (
26
+ <CollapsiblePrimitive.CollapsibleContent
27
+ data-slot="collapsible-content"
28
+ {...props}
29
+ />
30
+ )
31
+ }
32
+
33
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent }
src/components/ui/command.tsx ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Command as CommandPrimitive } from "cmdk"
5
+ import { SearchIcon } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from "@/components/ui/dialog"
15
+
16
+ function Command({
17
+ className,
18
+ ...props
19
+ }: React.ComponentProps<typeof CommandPrimitive>) {
20
+ return (
21
+ <CommandPrimitive
22
+ data-slot="command"
23
+ className={cn(
24
+ "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
25
+ className
26
+ )}
27
+ {...props}
28
+ />
29
+ )
30
+ }
31
+
32
+ function CommandDialog({
33
+ title = "Command Palette",
34
+ description = "Search for a command to run...",
35
+ children,
36
+ className,
37
+ showCloseButton = true,
38
+ ...props
39
+ }: React.ComponentProps<typeof Dialog> & {
40
+ title?: string
41
+ description?: string
42
+ className?: string
43
+ showCloseButton?: boolean
44
+ }) {
45
+ return (
46
+ <Dialog {...props}>
47
+ <DialogHeader className="sr-only">
48
+ <DialogTitle>{title}</DialogTitle>
49
+ <DialogDescription>{description}</DialogDescription>
50
+ </DialogHeader>
51
+ <DialogContent
52
+ className={cn("overflow-hidden p-0", className)}
53
+ showCloseButton={showCloseButton}
54
+ >
55
+ <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
56
+ {children}
57
+ </Command>
58
+ </DialogContent>
59
+ </Dialog>
60
+ )
61
+ }
62
+
63
+ function CommandInput({
64
+ className,
65
+ ...props
66
+ }: React.ComponentProps<typeof CommandPrimitive.Input>) {
67
+ return (
68
+ <div
69
+ data-slot="command-input-wrapper"
70
+ className="flex h-9 items-center gap-2 border-b px-3"
71
+ >
72
+ <SearchIcon className="size-4 shrink-0 opacity-50" />
73
+ <CommandPrimitive.Input
74
+ data-slot="command-input"
75
+ className={cn(
76
+ "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
77
+ className
78
+ )}
79
+ {...props}
80
+ />
81
+ </div>
82
+ )
83
+ }
84
+
85
+ function CommandList({
86
+ className,
87
+ ...props
88
+ }: React.ComponentProps<typeof CommandPrimitive.List>) {
89
+ return (
90
+ <CommandPrimitive.List
91
+ data-slot="command-list"
92
+ className={cn(
93
+ "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
94
+ className
95
+ )}
96
+ {...props}
97
+ />
98
+ )
99
+ }
100
+
101
+ function CommandEmpty({
102
+ ...props
103
+ }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
104
+ return (
105
+ <CommandPrimitive.Empty
106
+ data-slot="command-empty"
107
+ className="py-6 text-center text-sm"
108
+ {...props}
109
+ />
110
+ )
111
+ }
112
+
113
+ function CommandGroup({
114
+ className,
115
+ ...props
116
+ }: React.ComponentProps<typeof CommandPrimitive.Group>) {
117
+ return (
118
+ <CommandPrimitive.Group
119
+ data-slot="command-group"
120
+ className={cn(
121
+ "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
122
+ className
123
+ )}
124
+ {...props}
125
+ />
126
+ )
127
+ }
128
+
129
+ function CommandSeparator({
130
+ className,
131
+ ...props
132
+ }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
133
+ return (
134
+ <CommandPrimitive.Separator
135
+ data-slot="command-separator"
136
+ className={cn("bg-border -mx-1 h-px", className)}
137
+ {...props}
138
+ />
139
+ )
140
+ }
141
+
142
+ function CommandItem({
143
+ className,
144
+ ...props
145
+ }: React.ComponentProps<typeof CommandPrimitive.Item>) {
146
+ return (
147
+ <CommandPrimitive.Item
148
+ data-slot="command-item"
149
+ className={cn(
150
+ "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
151
+ className
152
+ )}
153
+ {...props}
154
+ />
155
+ )
156
+ }
157
+
158
+ function CommandShortcut({
159
+ className,
160
+ ...props
161
+ }: React.ComponentProps<"span">) {
162
+ return (
163
+ <span
164
+ data-slot="command-shortcut"
165
+ className={cn(
166
+ "text-muted-foreground ml-auto text-xs tracking-widest",
167
+ className
168
+ )}
169
+ {...props}
170
+ />
171
+ )
172
+ }
173
+
174
+ export {
175
+ Command,
176
+ CommandDialog,
177
+ CommandInput,
178
+ CommandList,
179
+ CommandEmpty,
180
+ CommandGroup,
181
+ CommandItem,
182
+ CommandShortcut,
183
+ CommandSeparator,
184
+ }
src/components/ui/context-menu.tsx ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
5
+ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function ContextMenu({
10
+ ...props
11
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
12
+ return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
13
+ }
14
+
15
+ function ContextMenuTrigger({
16
+ ...props
17
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
18
+ return (
19
+ <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
20
+ )
21
+ }
22
+
23
+ function ContextMenuGroup({
24
+ ...props
25
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
26
+ return (
27
+ <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
28
+ )
29
+ }
30
+
31
+ function ContextMenuPortal({
32
+ ...props
33
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
34
+ return (
35
+ <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
36
+ )
37
+ }
38
+
39
+ function ContextMenuSub({
40
+ ...props
41
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
42
+ return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
43
+ }
44
+
45
+ function ContextMenuRadioGroup({
46
+ ...props
47
+ }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
48
+ return (
49
+ <ContextMenuPrimitive.RadioGroup
50
+ data-slot="context-menu-radio-group"
51
+ {...props}
52
+ />
53
+ )
54
+ }
55
+
56
+ function ContextMenuSubTrigger({
57
+ className,
58
+ inset,
59
+ children,
60
+ ...props
61
+ }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
62
+ inset?: boolean
63
+ }) {
64
+ return (
65
+ <ContextMenuPrimitive.SubTrigger
66
+ data-slot="context-menu-sub-trigger"
67
+ data-inset={inset}
68
+ className={cn(
69
+ "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
70
+ className
71
+ )}
72
+ {...props}
73
+ >
74
+ {children}
75
+ <ChevronRightIcon className="ml-auto" />
76
+ </ContextMenuPrimitive.SubTrigger>
77
+ )
78
+ }
79
+
80
+ function ContextMenuSubContent({
81
+ className,
82
+ ...props
83
+ }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
84
+ return (
85
+ <ContextMenuPrimitive.SubContent
86
+ data-slot="context-menu-sub-content"
87
+ className={cn(
88
+ "bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
89
+ className
90
+ )}
91
+ {...props}
92
+ />
93
+ )
94
+ }
95
+
96
+ function ContextMenuContent({
97
+ className,
98
+ ...props
99
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
100
+ return (
101
+ <ContextMenuPrimitive.Portal>
102
+ <ContextMenuPrimitive.Content
103
+ data-slot="context-menu-content"
104
+ className={cn(
105
+ "bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
106
+ className
107
+ )}
108
+ {...props}
109
+ />
110
+ </ContextMenuPrimitive.Portal>
111
+ )
112
+ }
113
+
114
+ function ContextMenuItem({
115
+ className,
116
+ inset,
117
+ variant = "default",
118
+ ...props
119
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
120
+ inset?: boolean
121
+ variant?: "default" | "destructive"
122
+ }) {
123
+ return (
124
+ <ContextMenuPrimitive.Item
125
+ data-slot="context-menu-item"
126
+ data-inset={inset}
127
+ data-variant={variant}
128
+ className={cn(
129
+ "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
130
+ className
131
+ )}
132
+ {...props}
133
+ />
134
+ )
135
+ }
136
+
137
+ function ContextMenuCheckboxItem({
138
+ className,
139
+ children,
140
+ checked,
141
+ ...props
142
+ }: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
143
+ return (
144
+ <ContextMenuPrimitive.CheckboxItem
145
+ data-slot="context-menu-checkbox-item"
146
+ className={cn(
147
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
148
+ className
149
+ )}
150
+ checked={checked}
151
+ {...props}
152
+ >
153
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
154
+ <ContextMenuPrimitive.ItemIndicator>
155
+ <CheckIcon className="size-4" />
156
+ </ContextMenuPrimitive.ItemIndicator>
157
+ </span>
158
+ {children}
159
+ </ContextMenuPrimitive.CheckboxItem>
160
+ )
161
+ }
162
+
163
+ function ContextMenuRadioItem({
164
+ className,
165
+ children,
166
+ ...props
167
+ }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
168
+ return (
169
+ <ContextMenuPrimitive.RadioItem
170
+ data-slot="context-menu-radio-item"
171
+ className={cn(
172
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
173
+ className
174
+ )}
175
+ {...props}
176
+ >
177
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
178
+ <ContextMenuPrimitive.ItemIndicator>
179
+ <CircleIcon className="size-2 fill-current" />
180
+ </ContextMenuPrimitive.ItemIndicator>
181
+ </span>
182
+ {children}
183
+ </ContextMenuPrimitive.RadioItem>
184
+ )
185
+ }
186
+
187
+ function ContextMenuLabel({
188
+ className,
189
+ inset,
190
+ ...props
191
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
192
+ inset?: boolean
193
+ }) {
194
+ return (
195
+ <ContextMenuPrimitive.Label
196
+ data-slot="context-menu-label"
197
+ data-inset={inset}
198
+ className={cn(
199
+ "text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
200
+ className
201
+ )}
202
+ {...props}
203
+ />
204
+ )
205
+ }
206
+
207
+ function ContextMenuSeparator({
208
+ className,
209
+ ...props
210
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
211
+ return (
212
+ <ContextMenuPrimitive.Separator
213
+ data-slot="context-menu-separator"
214
+ className={cn("bg-border -mx-1 my-1 h-px", className)}
215
+ {...props}
216
+ />
217
+ )
218
+ }
219
+
220
+ function ContextMenuShortcut({
221
+ className,
222
+ ...props
223
+ }: React.ComponentProps<"span">) {
224
+ return (
225
+ <span
226
+ data-slot="context-menu-shortcut"
227
+ className={cn(
228
+ "text-muted-foreground ml-auto text-xs tracking-widest",
229
+ className
230
+ )}
231
+ {...props}
232
+ />
233
+ )
234
+ }
235
+
236
+ export {
237
+ ContextMenu,
238
+ ContextMenuTrigger,
239
+ ContextMenuContent,
240
+ ContextMenuItem,
241
+ ContextMenuCheckboxItem,
242
+ ContextMenuRadioItem,
243
+ ContextMenuLabel,
244
+ ContextMenuSeparator,
245
+ ContextMenuShortcut,
246
+ ContextMenuGroup,
247
+ ContextMenuPortal,
248
+ ContextMenuSub,
249
+ ContextMenuSubContent,
250
+ ContextMenuSubTrigger,
251
+ ContextMenuRadioGroup,
252
+ }
src/components/ui/dialog.tsx ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog"
5
+ import { XIcon } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function Dialog({
10
+ ...props
11
+ }: React.ComponentProps<typeof DialogPrimitive.Root>) {
12
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />
13
+ }
14
+
15
+ function DialogTrigger({
16
+ ...props
17
+ }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
18
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
19
+ }
20
+
21
+ function DialogPortal({
22
+ ...props
23
+ }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
24
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
25
+ }
26
+
27
+ function DialogClose({
28
+ ...props
29
+ }: React.ComponentProps<typeof DialogPrimitive.Close>) {
30
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
31
+ }
32
+
33
+ function DialogOverlay({
34
+ className,
35
+ ...props
36
+ }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
37
+ return (
38
+ <DialogPrimitive.Overlay
39
+ data-slot="dialog-overlay"
40
+ className={cn(
41
+ "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",
42
+ className
43
+ )}
44
+ {...props}
45
+ />
46
+ )
47
+ }
48
+
49
+ function DialogContent({
50
+ className,
51
+ children,
52
+ showCloseButton = true,
53
+ ...props
54
+ }: React.ComponentProps<typeof DialogPrimitive.Content> & {
55
+ showCloseButton?: boolean
56
+ }) {
57
+ return (
58
+ <DialogPortal data-slot="dialog-portal">
59
+ <DialogOverlay />
60
+ <DialogPrimitive.Content
61
+ data-slot="dialog-content"
62
+ className={cn(
63
+ "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",
64
+ className
65
+ )}
66
+ {...props}
67
+ >
68
+ {children}
69
+ {showCloseButton && (
70
+ <DialogPrimitive.Close
71
+ data-slot="dialog-close"
72
+ className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
73
+ >
74
+ <XIcon />
75
+ <span className="sr-only">Close</span>
76
+ </DialogPrimitive.Close>
77
+ )}
78
+ </DialogPrimitive.Content>
79
+ </DialogPortal>
80
+ )
81
+ }
82
+
83
+ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
84
+ return (
85
+ <div
86
+ data-slot="dialog-header"
87
+ className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
88
+ {...props}
89
+ />
90
+ )
91
+ }
92
+
93
+ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
94
+ return (
95
+ <div
96
+ data-slot="dialog-footer"
97
+ className={cn(
98
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
99
+ className
100
+ )}
101
+ {...props}
102
+ />
103
+ )
104
+ }
105
+
106
+ function DialogTitle({
107
+ className,
108
+ ...props
109
+ }: React.ComponentProps<typeof DialogPrimitive.Title>) {
110
+ return (
111
+ <DialogPrimitive.Title
112
+ data-slot="dialog-title"
113
+ className={cn("text-lg leading-none font-semibold", className)}
114
+ {...props}
115
+ />
116
+ )
117
+ }
118
+
119
+ function DialogDescription({
120
+ className,
121
+ ...props
122
+ }: React.ComponentProps<typeof DialogPrimitive.Description>) {
123
+ return (
124
+ <DialogPrimitive.Description
125
+ data-slot="dialog-description"
126
+ className={cn("text-muted-foreground text-sm", className)}
127
+ {...props}
128
+ />
129
+ )
130
+ }
131
+
132
+ export {
133
+ Dialog,
134
+ DialogClose,
135
+ DialogContent,
136
+ DialogDescription,
137
+ DialogFooter,
138
+ DialogHeader,
139
+ DialogOverlay,
140
+ DialogPortal,
141
+ DialogTitle,
142
+ DialogTrigger,
143
+ }
src/components/ui/drawer.tsx ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Drawer as DrawerPrimitive } from "vaul"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Drawer({
9
+ ...props
10
+ }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
11
+ return <DrawerPrimitive.Root data-slot="drawer" {...props} />
12
+ }
13
+
14
+ function DrawerTrigger({
15
+ ...props
16
+ }: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
17
+ return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
18
+ }
19
+
20
+ function DrawerPortal({
21
+ ...props
22
+ }: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
23
+ return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
24
+ }
25
+
26
+ function DrawerClose({
27
+ ...props
28
+ }: React.ComponentProps<typeof DrawerPrimitive.Close>) {
29
+ return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
30
+ }
31
+
32
+ function DrawerOverlay({
33
+ className,
34
+ ...props
35
+ }: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
36
+ return (
37
+ <DrawerPrimitive.Overlay
38
+ data-slot="drawer-overlay"
39
+ className={cn(
40
+ "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",
41
+ className
42
+ )}
43
+ {...props}
44
+ />
45
+ )
46
+ }
47
+
48
+ function DrawerContent({
49
+ className,
50
+ children,
51
+ ...props
52
+ }: React.ComponentProps<typeof DrawerPrimitive.Content>) {
53
+ return (
54
+ <DrawerPortal data-slot="drawer-portal">
55
+ <DrawerOverlay />
56
+ <DrawerPrimitive.Content
57
+ data-slot="drawer-content"
58
+ className={cn(
59
+ "group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
60
+ "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
61
+ "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
62
+ "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
63
+ "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
64
+ className
65
+ )}
66
+ {...props}
67
+ >
68
+ <div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
69
+ {children}
70
+ </DrawerPrimitive.Content>
71
+ </DrawerPortal>
72
+ )
73
+ }
74
+
75
+ function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
76
+ return (
77
+ <div
78
+ data-slot="drawer-header"
79
+ className={cn(
80
+ "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
81
+ className
82
+ )}
83
+ {...props}
84
+ />
85
+ )
86
+ }
87
+
88
+ function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
89
+ return (
90
+ <div
91
+ data-slot="drawer-footer"
92
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
93
+ {...props}
94
+ />
95
+ )
96
+ }
97
+
98
+ function DrawerTitle({
99
+ className,
100
+ ...props
101
+ }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
102
+ return (
103
+ <DrawerPrimitive.Title
104
+ data-slot="drawer-title"
105
+ className={cn("text-foreground font-semibold", className)}
106
+ {...props}
107
+ />
108
+ )
109
+ }
110
+
111
+ function DrawerDescription({
112
+ className,
113
+ ...props
114
+ }: React.ComponentProps<typeof DrawerPrimitive.Description>) {
115
+ return (
116
+ <DrawerPrimitive.Description
117
+ data-slot="drawer-description"
118
+ className={cn("text-muted-foreground text-sm", className)}
119
+ {...props}
120
+ />
121
+ )
122
+ }
123
+
124
+ export {
125
+ Drawer,
126
+ DrawerPortal,
127
+ DrawerOverlay,
128
+ DrawerTrigger,
129
+ DrawerClose,
130
+ DrawerContent,
131
+ DrawerHeader,
132
+ DrawerFooter,
133
+ DrawerTitle,
134
+ DrawerDescription,
135
+ }
src/components/ui/dropdown-menu.tsx ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5
+ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function DropdownMenu({
10
+ ...props
11
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
12
+ return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
13
+ }
14
+
15
+ function DropdownMenuPortal({
16
+ ...props
17
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
18
+ return (
19
+ <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
20
+ )
21
+ }
22
+
23
+ function DropdownMenuTrigger({
24
+ ...props
25
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
26
+ return (
27
+ <DropdownMenuPrimitive.Trigger
28
+ data-slot="dropdown-menu-trigger"
29
+ {...props}
30
+ />
31
+ )
32
+ }
33
+
34
+ function DropdownMenuContent({
35
+ className,
36
+ sideOffset = 4,
37
+ ...props
38
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
39
+ return (
40
+ <DropdownMenuPrimitive.Portal>
41
+ <DropdownMenuPrimitive.Content
42
+ data-slot="dropdown-menu-content"
43
+ sideOffset={sideOffset}
44
+ className={cn(
45
+ "bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
46
+ className
47
+ )}
48
+ {...props}
49
+ />
50
+ </DropdownMenuPrimitive.Portal>
51
+ )
52
+ }
53
+
54
+ function DropdownMenuGroup({
55
+ ...props
56
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
57
+ return (
58
+ <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
59
+ )
60
+ }
61
+
62
+ function DropdownMenuItem({
63
+ className,
64
+ inset,
65
+ variant = "default",
66
+ ...props
67
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
68
+ inset?: boolean
69
+ variant?: "default" | "destructive"
70
+ }) {
71
+ return (
72
+ <DropdownMenuPrimitive.Item
73
+ data-slot="dropdown-menu-item"
74
+ data-inset={inset}
75
+ data-variant={variant}
76
+ className={cn(
77
+ "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
78
+ className
79
+ )}
80
+ {...props}
81
+ />
82
+ )
83
+ }
84
+
85
+ function DropdownMenuCheckboxItem({
86
+ className,
87
+ children,
88
+ checked,
89
+ ...props
90
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
91
+ return (
92
+ <DropdownMenuPrimitive.CheckboxItem
93
+ data-slot="dropdown-menu-checkbox-item"
94
+ className={cn(
95
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
96
+ className
97
+ )}
98
+ checked={checked}
99
+ {...props}
100
+ >
101
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
102
+ <DropdownMenuPrimitive.ItemIndicator>
103
+ <CheckIcon className="size-4" />
104
+ </DropdownMenuPrimitive.ItemIndicator>
105
+ </span>
106
+ {children}
107
+ </DropdownMenuPrimitive.CheckboxItem>
108
+ )
109
+ }
110
+
111
+ function DropdownMenuRadioGroup({
112
+ ...props
113
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
114
+ return (
115
+ <DropdownMenuPrimitive.RadioGroup
116
+ data-slot="dropdown-menu-radio-group"
117
+ {...props}
118
+ />
119
+ )
120
+ }
121
+
122
+ function DropdownMenuRadioItem({
123
+ className,
124
+ children,
125
+ ...props
126
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
127
+ return (
128
+ <DropdownMenuPrimitive.RadioItem
129
+ data-slot="dropdown-menu-radio-item"
130
+ className={cn(
131
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
132
+ className
133
+ )}
134
+ {...props}
135
+ >
136
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
137
+ <DropdownMenuPrimitive.ItemIndicator>
138
+ <CircleIcon className="size-2 fill-current" />
139
+ </DropdownMenuPrimitive.ItemIndicator>
140
+ </span>
141
+ {children}
142
+ </DropdownMenuPrimitive.RadioItem>
143
+ )
144
+ }
145
+
146
+ function DropdownMenuLabel({
147
+ className,
148
+ inset,
149
+ ...props
150
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
151
+ inset?: boolean
152
+ }) {
153
+ return (
154
+ <DropdownMenuPrimitive.Label
155
+ data-slot="dropdown-menu-label"
156
+ data-inset={inset}
157
+ className={cn(
158
+ "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
159
+ className
160
+ )}
161
+ {...props}
162
+ />
163
+ )
164
+ }
165
+
166
+ function DropdownMenuSeparator({
167
+ className,
168
+ ...props
169
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
170
+ return (
171
+ <DropdownMenuPrimitive.Separator
172
+ data-slot="dropdown-menu-separator"
173
+ className={cn("bg-border -mx-1 my-1 h-px", className)}
174
+ {...props}
175
+ />
176
+ )
177
+ }
178
+
179
+ function DropdownMenuShortcut({
180
+ className,
181
+ ...props
182
+ }: React.ComponentProps<"span">) {
183
+ return (
184
+ <span
185
+ data-slot="dropdown-menu-shortcut"
186
+ className={cn(
187
+ "text-muted-foreground ml-auto text-xs tracking-widest",
188
+ className
189
+ )}
190
+ {...props}
191
+ />
192
+ )
193
+ }
194
+
195
+ function DropdownMenuSub({
196
+ ...props
197
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
198
+ return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
199
+ }
200
+
201
+ function DropdownMenuSubTrigger({
202
+ className,
203
+ inset,
204
+ children,
205
+ ...props
206
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
207
+ inset?: boolean
208
+ }) {
209
+ return (
210
+ <DropdownMenuPrimitive.SubTrigger
211
+ data-slot="dropdown-menu-sub-trigger"
212
+ data-inset={inset}
213
+ className={cn(
214
+ "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
215
+ className
216
+ )}
217
+ {...props}
218
+ >
219
+ {children}
220
+ <ChevronRightIcon className="ml-auto size-4" />
221
+ </DropdownMenuPrimitive.SubTrigger>
222
+ )
223
+ }
224
+
225
+ function DropdownMenuSubContent({
226
+ className,
227
+ ...props
228
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
229
+ return (
230
+ <DropdownMenuPrimitive.SubContent
231
+ data-slot="dropdown-menu-sub-content"
232
+ className={cn(
233
+ "bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
234
+ className
235
+ )}
236
+ {...props}
237
+ />
238
+ )
239
+ }
240
+
241
+ export {
242
+ DropdownMenu,
243
+ DropdownMenuPortal,
244
+ DropdownMenuTrigger,
245
+ DropdownMenuContent,
246
+ DropdownMenuGroup,
247
+ DropdownMenuLabel,
248
+ DropdownMenuItem,
249
+ DropdownMenuCheckboxItem,
250
+ DropdownMenuRadioGroup,
251
+ DropdownMenuRadioItem,
252
+ DropdownMenuSeparator,
253
+ DropdownMenuShortcut,
254
+ DropdownMenuSub,
255
+ DropdownMenuSubTrigger,
256
+ DropdownMenuSubContent,
257
+ }
src/components/ui/form.tsx ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as LabelPrimitive from "@radix-ui/react-label"
5
+ import { Slot } from "@radix-ui/react-slot"
6
+ import {
7
+ Controller,
8
+ FormProvider,
9
+ useFormContext,
10
+ useFormState,
11
+ type ControllerProps,
12
+ type FieldPath,
13
+ type FieldValues,
14
+ } from "react-hook-form"
15
+
16
+ import { cn } from "@/lib/utils"
17
+ import { Label } from "@/components/ui/label"
18
+
19
+ const Form = FormProvider
20
+
21
+ type FormFieldContextValue<
22
+ TFieldValues extends FieldValues = FieldValues,
23
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
24
+ > = {
25
+ name: TName
26
+ }
27
+
28
+ const FormFieldContext = React.createContext<FormFieldContextValue>(
29
+ {} as FormFieldContextValue
30
+ )
31
+
32
+ const FormField = <
33
+ TFieldValues extends FieldValues = FieldValues,
34
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
35
+ >({
36
+ ...props
37
+ }: ControllerProps<TFieldValues, TName>) => {
38
+ return (
39
+ <FormFieldContext.Provider value={{ name: props.name }}>
40
+ <Controller {...props} />
41
+ </FormFieldContext.Provider>
42
+ )
43
+ }
44
+
45
+ const useFormField = () => {
46
+ const fieldContext = React.useContext(FormFieldContext)
47
+ const itemContext = React.useContext(FormItemContext)
48
+ const { getFieldState } = useFormContext()
49
+ const formState = useFormState({ name: fieldContext.name })
50
+ const fieldState = getFieldState(fieldContext.name, formState)
51
+
52
+ if (!fieldContext) {
53
+ throw new Error("useFormField should be used within <FormField>")
54
+ }
55
+
56
+ const { id } = itemContext
57
+
58
+ return {
59
+ id,
60
+ name: fieldContext.name,
61
+ formItemId: `${id}-form-item`,
62
+ formDescriptionId: `${id}-form-item-description`,
63
+ formMessageId: `${id}-form-item-message`,
64
+ ...fieldState,
65
+ }
66
+ }
67
+
68
+ type FormItemContextValue = {
69
+ id: string
70
+ }
71
+
72
+ const FormItemContext = React.createContext<FormItemContextValue>(
73
+ {} as FormItemContextValue
74
+ )
75
+
76
+ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
77
+ const id = React.useId()
78
+
79
+ return (
80
+ <FormItemContext.Provider value={{ id }}>
81
+ <div
82
+ data-slot="form-item"
83
+ className={cn("grid gap-2", className)}
84
+ {...props}
85
+ />
86
+ </FormItemContext.Provider>
87
+ )
88
+ }
89
+
90
+ function FormLabel({
91
+ className,
92
+ ...props
93
+ }: React.ComponentProps<typeof LabelPrimitive.Root>) {
94
+ const { error, formItemId } = useFormField()
95
+
96
+ return (
97
+ <Label
98
+ data-slot="form-label"
99
+ data-error={!!error}
100
+ className={cn("data-[error=true]:text-destructive", className)}
101
+ htmlFor={formItemId}
102
+ {...props}
103
+ />
104
+ )
105
+ }
106
+
107
+ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
108
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109
+
110
+ return (
111
+ <Slot
112
+ data-slot="form-control"
113
+ id={formItemId}
114
+ aria-describedby={
115
+ !error
116
+ ? `${formDescriptionId}`
117
+ : `${formDescriptionId} ${formMessageId}`
118
+ }
119
+ aria-invalid={!!error}
120
+ {...props}
121
+ />
122
+ )
123
+ }
124
+
125
+ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
126
+ const { formDescriptionId } = useFormField()
127
+
128
+ return (
129
+ <p
130
+ data-slot="form-description"
131
+ id={formDescriptionId}
132
+ className={cn("text-muted-foreground text-sm", className)}
133
+ {...props}
134
+ />
135
+ )
136
+ }
137
+
138
+ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
139
+ const { error, formMessageId } = useFormField()
140
+ const body = error ? String(error?.message ?? "") : props.children
141
+
142
+ if (!body) {
143
+ return null
144
+ }
145
+
146
+ return (
147
+ <p
148
+ data-slot="form-message"
149
+ id={formMessageId}
150
+ className={cn("text-destructive text-sm", className)}
151
+ {...props}
152
+ >
153
+ {body}
154
+ </p>
155
+ )
156
+ }
157
+
158
+ export {
159
+ useFormField,
160
+ Form,
161
+ FormItem,
162
+ FormLabel,
163
+ FormControl,
164
+ FormDescription,
165
+ FormMessage,
166
+ FormField,
167
+ }
src/components/ui/hover-card.tsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function HoverCard({
9
+ ...props
10
+ }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
11
+ return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
12
+ }
13
+
14
+ function HoverCardTrigger({
15
+ ...props
16
+ }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
17
+ return (
18
+ <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
19
+ )
20
+ }
21
+
22
+ function HoverCardContent({
23
+ className,
24
+ align = "center",
25
+ sideOffset = 4,
26
+ ...props
27
+ }: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
28
+ return (
29
+ <HoverCardPrimitive.Portal data-slot="hover-card-portal">
30
+ <HoverCardPrimitive.Content
31
+ data-slot="hover-card-content"
32
+ align={align}
33
+ sideOffset={sideOffset}
34
+ className={cn(
35
+ "bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
36
+ className
37
+ )}
38
+ {...props}
39
+ />
40
+ </HoverCardPrimitive.Portal>
41
+ )
42
+ }
43
+
44
+ export { HoverCard, HoverCardTrigger, HoverCardContent }
src/components/ui/input-otp.tsx ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { OTPInput, OTPInputContext } from "input-otp"
5
+ import { MinusIcon } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function InputOTP({
10
+ className,
11
+ containerClassName,
12
+ ...props
13
+ }: React.ComponentProps<typeof OTPInput> & {
14
+ containerClassName?: string
15
+ }) {
16
+ return (
17
+ <OTPInput
18
+ data-slot="input-otp"
19
+ containerClassName={cn(
20
+ "flex items-center gap-2 has-disabled:opacity-50",
21
+ containerClassName
22
+ )}
23
+ className={cn("disabled:cursor-not-allowed", className)}
24
+ {...props}
25
+ />
26
+ )
27
+ }
28
+
29
+ function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
30
+ return (
31
+ <div
32
+ data-slot="input-otp-group"
33
+ className={cn("flex items-center", className)}
34
+ {...props}
35
+ />
36
+ )
37
+ }
38
+
39
+ function InputOTPSlot({
40
+ index,
41
+ className,
42
+ ...props
43
+ }: React.ComponentProps<"div"> & {
44
+ index: number
45
+ }) {
46
+ const inputOTPContext = React.useContext(OTPInputContext)
47
+ const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
48
+
49
+ return (
50
+ <div
51
+ data-slot="input-otp-slot"
52
+ data-active={isActive}
53
+ className={cn(
54
+ "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
55
+ className
56
+ )}
57
+ {...props}
58
+ >
59
+ {char}
60
+ {hasFakeCaret && (
61
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
62
+ <div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
63
+ </div>
64
+ )}
65
+ </div>
66
+ )
67
+ }
68
+
69
+ function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
70
+ return (
71
+ <div data-slot="input-otp-separator" role="separator" {...props}>
72
+ <MinusIcon />
73
+ </div>
74
+ )
75
+ }
76
+
77
+ export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
src/components/ui/input.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6
+ return (
7
+ <input
8
+ type={type}
9
+ data-slot="input"
10
+ className={cn(
11
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
12
+ "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
13
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
14
+ className
15
+ )}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ export { Input }