frontend admin changes
This commit is contained in:
parent
9d95f25f16
commit
2264f58f07
199
package-lock.json
generated
199
package-lock.json
generated
@ -19,6 +19,7 @@
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@tldraw/tldraw": "^3.15.4",
|
||||
"axios": "^1.11.0",
|
||||
@ -28,6 +29,7 @@
|
||||
"next": "15.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"svg-path-parser": "^1.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.8"
|
||||
@ -2572,6 +2574,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@ -2809,6 +2817,66 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.4.3",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.0.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.3",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.0.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.11",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@tybys/wasm-util": "^0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.9.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
|
||||
@ -4833,6 +4901,45 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
|
||||
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.17.1",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||
@ -7061,7 +7168,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@ -8318,6 +8424,68 @@
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.2",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
||||
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@ -9074,6 +9242,35 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev --turbopack -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@tldraw/tldraw": "^3.15.4",
|
||||
"axios": "^1.11.0",
|
||||
@ -29,6 +30,7 @@
|
||||
"next": "15.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"svg-path-parser": "^1.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.8"
|
||||
|
||||
88
src/app/admin/analytics/page.tsx
Normal file
88
src/app/admin/analytics/page.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
|
||||
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { BarChart3, TrendingUp, Activity, Eye } from 'lucide-react'
|
||||
|
||||
export default function AdminAnalyticsPage() {
|
||||
return (
|
||||
<AdminNotificationProvider>
|
||||
<AdminSidebarLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Analytics Dashboard</h1>
|
||||
<p className="text-white/70">Monitor system performance and user engagement</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Page Views</CardTitle>
|
||||
<Eye className="h-4 w-4 text-white/60" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">45,231</div>
|
||||
<p className="text-xs text-white/60">+20.1% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Active Sessions</CardTitle>
|
||||
<Activity className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">2,350</div>
|
||||
<p className="text-xs text-white/60">+180.1% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Template Usage</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">12,234</div>
|
||||
<p className="text-xs text-white/60">+19% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Growth Rate</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">+12.5%</div>
|
||||
<p className="text-xs text-white/60">+2.1% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Analytics Content */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Analytics Features</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-white/70">
|
||||
<p>Advanced analytics functionality will be implemented here.</p>
|
||||
<p className="mt-2">Features will include:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Real-time user activity tracking</li>
|
||||
<li>Template usage statistics</li>
|
||||
<li>Performance metrics</li>
|
||||
<li>Custom reports generation</li>
|
||||
<li>Data visualization charts</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminSidebarLayout>
|
||||
</AdminNotificationProvider>
|
||||
)
|
||||
}
|
||||
@ -1,59 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { AdminDashboard } from '@/components/admin/admin-dashboard'
|
||||
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
|
||||
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user, isAdmin } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect non-admin users to home page
|
||||
if (user && !isAdmin) {
|
||||
router.push('/')
|
||||
}
|
||||
// Redirect unauthenticated users to signin
|
||||
if (!user) {
|
||||
router.push('/signin')
|
||||
}
|
||||
}, [user, isAdmin, router])
|
||||
|
||||
// Show loading while checking auth
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||||
<p className="text-white/60">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show access denied for non-admin users
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 text-6xl mb-4">🚫</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Access Denied</h1>
|
||||
<p className="text-white/60 mb-4">You don't have permission to access this page.</p>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="px-4 py-2 bg-orange-500 text-black rounded-md hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Go to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<AdminDashboard />
|
||||
</div>
|
||||
<AdminNotificationProvider>
|
||||
<AdminSidebarLayout>
|
||||
<AdminDashboard />
|
||||
</AdminSidebarLayout>
|
||||
</AdminNotificationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
186
src/app/admin/settings/page.tsx
Normal file
186
src/app/admin/settings/page.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
"use client"
|
||||
|
||||
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
|
||||
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Settings, Save, Database, Mail, Shield } from 'lucide-react'
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return (
|
||||
<AdminNotificationProvider>
|
||||
<AdminSidebarLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">System Settings</h1>
|
||||
<p className="text-white/70">Configure system preferences and security settings</p>
|
||||
</div>
|
||||
<Button className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* General Settings */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
General Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site-name" className="text-white">Site Name</Label>
|
||||
<Input
|
||||
id="site-name"
|
||||
defaultValue="Codenuk"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site-description" className="text-white">Site Description</Label>
|
||||
<Input
|
||||
id="site-description"
|
||||
defaultValue="AI-powered code generation platform"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="maintenance-mode" className="text-white">Maintenance Mode</Label>
|
||||
<Switch id="maintenance-mode" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="user-registration" className="text-white">Allow User Registration</Label>
|
||||
<Switch id="user-registration" defaultChecked />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security Settings */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Shield className="h-5 w-5 mr-2" />
|
||||
Security Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="two-factor" className="text-white">Require 2FA for Admins</Label>
|
||||
<Switch id="two-factor" defaultChecked />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="session-timeout" className="text-white">Auto Session Timeout</Label>
|
||||
<Switch id="session-timeout" defaultChecked />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-login-attempts" className="text-white">Max Login Attempts</Label>
|
||||
<Input
|
||||
id="max-login-attempts"
|
||||
type="number"
|
||||
defaultValue="5"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password-min-length" className="text-white">Min Password Length</Label>
|
||||
<Input
|
||||
id="password-min-length"
|
||||
type="number"
|
||||
defaultValue="8"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Settings */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Mail className="h-5 w-5 mr-2" />
|
||||
Email Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtp-host" className="text-white">SMTP Host</Label>
|
||||
<Input
|
||||
id="smtp-host"
|
||||
placeholder="smtp.example.com"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtp-port" className="text-white">SMTP Port</Label>
|
||||
<Input
|
||||
id="smtp-port"
|
||||
type="number"
|
||||
defaultValue="587"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from-email" className="text-white">From Email</Label>
|
||||
<Input
|
||||
id="from-email"
|
||||
placeholder="noreply@codenuk.com"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="email-notifications" className="text-white">Email Notifications</Label>
|
||||
<Switch id="email-notifications" defaultChecked />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Database Settings */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Database className="h-5 w-5 mr-2" />
|
||||
Database Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="auto-backup" className="text-white">Auto Backup</Label>
|
||||
<Switch id="auto-backup" defaultChecked />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-frequency" className="text-white">Backup Frequency (hours)</Label>
|
||||
<Input
|
||||
id="backup-frequency"
|
||||
type="number"
|
||||
defaultValue="24"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retention-days" className="text-white">Backup Retention (days)</Label>
|
||||
<Input
|
||||
id="retention-days"
|
||||
type="number"
|
||||
defaultValue="30"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full border-white/20 text-white hover:bg-white/10">
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
Run Manual Backup
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSidebarLayout>
|
||||
</AdminNotificationProvider>
|
||||
)
|
||||
}
|
||||
15
src/app/admin/templates/page.tsx
Normal file
15
src/app/admin/templates/page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
|
||||
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
|
||||
import { AdminTemplatesManager } from '@/components/admin/admin-templates-manager'
|
||||
|
||||
export default function AdminTemplatesPage() {
|
||||
return (
|
||||
<AdminNotificationProvider>
|
||||
<AdminSidebarLayout>
|
||||
<AdminTemplatesManager />
|
||||
</AdminSidebarLayout>
|
||||
</AdminNotificationProvider>
|
||||
)
|
||||
}
|
||||
88
src/app/admin/users/page.tsx
Normal file
88
src/app/admin/users/page.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
|
||||
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Users, UserPlus, UserCheck, UserX } from 'lucide-react'
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
return (
|
||||
<AdminNotificationProvider>
|
||||
<AdminSidebarLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">User Management</h1>
|
||||
<p className="text-white/70">Manage user accounts and permissions</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Total Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-white/60" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">1,234</div>
|
||||
<p className="text-xs text-white/60">+12% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">New Users</CardTitle>
|
||||
<UserPlus className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">89</div>
|
||||
<p className="text-xs text-white/60">This month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Active Users</CardTitle>
|
||||
<UserCheck className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">987</div>
|
||||
<p className="text-xs text-white/60">80% of total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Inactive Users</CardTitle>
|
||||
<UserX className="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">247</div>
|
||||
<p className="text-xs text-white/60">20% of total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* User Management Content */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">User Management Features</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-white/70">
|
||||
<p>User management functionality will be implemented here.</p>
|
||||
<p className="mt-2">Features will include:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>View all users</li>
|
||||
<li>Search and filter users</li>
|
||||
<li>Edit user permissions</li>
|
||||
<li>Activate/deactivate accounts</li>
|
||||
<li>View user activity logs</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminSidebarLayout>
|
||||
</AdminNotificationProvider>
|
||||
)
|
||||
}
|
||||
@ -77,7 +77,8 @@ Return ONLY a JSON object in this exact format:
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0])
|
||||
const complexity = (parsed.complexity as 'low' | 'medium' | 'high') || 'medium'
|
||||
// Use the user-selected complexity if provided, otherwise use AI's suggestion
|
||||
const complexity = (body.complexity || parsed.complexity as 'low' | 'medium' | 'high') || 'medium'
|
||||
const logicRules = Array.isArray(parsed.logicRules) ? parsed.logicRules : []
|
||||
|
||||
return NextResponse.json({ success: true, data: { complexity, logicRules } })
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { BACKEND_URL } from "@/config/backend";
|
||||
|
||||
type VerifyState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
@ -9,14 +10,11 @@ interface VerificationResponse {
|
||||
success: boolean;
|
||||
data: { message: string; user: { email: string; username: string } };
|
||||
message: string;
|
||||
redirect?: string;
|
||||
}
|
||||
interface ErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
// Removed unused ErrorResponse interface
|
||||
|
||||
|
||||
const API_BASE_URL = "http://localhost:8011";
|
||||
|
||||
const EmailVerification: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
@ -51,30 +49,37 @@ const EmailVerification: React.FC = () => {
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/auth/verify-email?token=${verificationToken}`,
|
||||
{ method: "GET", headers: { "Content-Type": "application/json" }, signal: ctrl.signal }
|
||||
`${BACKEND_URL}/api/auth/verify-email?token=${verificationToken}&format=json`,
|
||||
{ method: "GET", headers: { "Accept": "application/json" }, signal: ctrl.signal }
|
||||
);
|
||||
|
||||
const txt = await res.text();
|
||||
let data: any = {};
|
||||
try { data = JSON.parse(txt || "{}"); } catch { /* ignore non-JSON */ }
|
||||
console.log("Raw response text:", txt);
|
||||
console.log("Response status:", res.status);
|
||||
console.log("Response headers:", Object.fromEntries(res.headers.entries()));
|
||||
|
||||
if (res.ok && (data as VerificationResponse)?.success) {
|
||||
router.replace("/auth?verified=1");
|
||||
let data: Record<string, unknown> = {};
|
||||
try { data = JSON.parse(txt || "{}"); } catch { /* ignore non-JSON */ }
|
||||
console.log("Parsed response data:", data);
|
||||
|
||||
if (res.ok && (data as unknown as VerificationResponse)?.success) {
|
||||
router.replace("/signin?verified=true");
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = String(data?.message || "").toLowerCase();
|
||||
if (msg.includes("already verified")) {
|
||||
router.replace("/auth?verified=1");
|
||||
// Check if there's a redirect URL in the response for already verified case too
|
||||
router.replace("/signin?verified=true");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("error");
|
||||
setError(data?.message || `Verification failed (HTTP ${res.status}).`);
|
||||
} catch (e: any) {
|
||||
setError(String(data?.message) || `Verification failed (HTTP ${res.status}).`);
|
||||
} catch (e: unknown) {
|
||||
setStatus("error");
|
||||
setError(e?.name === "AbortError" ? "Request timed out. Please try again." : "Network error. Please try again.");
|
||||
const error = e as { name?: string };
|
||||
setError(error?.name === "AbortError" ? "Request timed out. Please try again." : "Network error. Please try again.");
|
||||
console.error("Email verification error:", e);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
@ -94,7 +99,7 @@ const EmailVerification: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/resend-verification`, {
|
||||
const res = await fetch(`${BACKEND_URL}/api/auth/resend-verification`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
|
||||
@ -3,6 +3,7 @@ import type { Metadata } from "next"
|
||||
import { Poppins } from "next/font/google"
|
||||
import { AuthProvider } from "@/contexts/auth-context"
|
||||
import { AppLayout } from "@/components/layout/app-layout"
|
||||
import { ToastProvider } from "@/components/ui/toast"
|
||||
import "./globals.css"
|
||||
|
||||
const poppins = Poppins({
|
||||
@ -33,11 +34,13 @@ html {
|
||||
`}</style>
|
||||
</head>
|
||||
<body className="font-sans antialiased dark bg-black text-white">
|
||||
<AuthProvider>
|
||||
<AppLayout>
|
||||
<main>{children}</main>
|
||||
</AppLayout>
|
||||
</AuthProvider>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<AppLayout>
|
||||
<main>{children}</main>
|
||||
</AppLayout>
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { Suspense } from "react"
|
||||
import { SignInPage } from "@/components/auth/signin-page"
|
||||
|
||||
export default function SignInPageRoute() {
|
||||
return <SignInPage />
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<SignInPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import EmailVerification from "@/app/auth/emailVerification";
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return <EmailVerification />;
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<EmailVerification />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,34 +6,55 @@ import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
Copy,
|
||||
Bell,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Filter,
|
||||
Search,
|
||||
Filter
|
||||
Edit,
|
||||
Zap,
|
||||
Files,
|
||||
Bell,
|
||||
BarChart3
|
||||
} from 'lucide-react'
|
||||
import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin'
|
||||
import { AdminFeature, AdminNotification, AdminStats } from '@/types/admin.types'
|
||||
import { AdminFeature, AdminTemplate, AdminStats } from '@/types/admin.types'
|
||||
import { FeatureReviewDialog } from './feature-review-dialog'
|
||||
import { AdminNotificationsPanel } from './admin-notifications-panel'
|
||||
import { FeatureEditDialog } from './feature-edit-dialog'
|
||||
import { TemplateEditDialog } from './template-edit-dialog'
|
||||
import { RejectDialog } from './reject-dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { AdminNotificationProvider, useAdminNotifications } from '@/contexts/AdminNotificationContext'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
function AdminDashboardContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const activeTab = searchParams.get('tab') || 'dashboard'
|
||||
const filterParam = searchParams.get('filter') || 'all'
|
||||
|
||||
export function AdminDashboard() {
|
||||
const [pendingFeatures, setPendingFeatures] = useState<AdminFeature[]>([])
|
||||
const [notifications, setNotifications] = useState<AdminNotification[]>([])
|
||||
const [customTemplates, setCustomTemplates] = useState<AdminTemplate[]>([])
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [templateStats, setTemplateStats] = useState<AdminStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedFeature, setSelectedFeature] = useState<AdminFeature | null>(null)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<AdminTemplate | null>(null)
|
||||
const [showReviewDialog, setShowReviewDialog] = useState(false)
|
||||
const [showFeatureEditDialog, setShowFeatureEditDialog] = useState(false)
|
||||
const [showTemplateEditDialog, setShowTemplateEditDialog] = useState(false)
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false)
|
||||
const [rejectItem, setRejectItem] = useState<{ id: string; name: string; type: 'feature' | 'template' } | null>(null)
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>(filterParam)
|
||||
|
||||
const { unreadCount, removeByReference } = useAdminNotifications()
|
||||
|
||||
// Load dashboard data
|
||||
const loadDashboardData = async () => {
|
||||
@ -41,15 +62,17 @@ export function AdminDashboard() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [pendingData, notificationsData, statsData] = await Promise.all([
|
||||
adminApi.getPendingFeatures(),
|
||||
adminApi.getNotifications(true, 10), // Get 10 unread notifications
|
||||
adminApi.getFeatureStats()
|
||||
const [featuresResponse, templatesResponse, featureStatsResponse, templateStatsResponse] = await Promise.all([
|
||||
adminApi.getCustomFeatures('pending'),
|
||||
adminApi.getCustomTemplates('pending'),
|
||||
adminApi.getCustomFeatureStats(),
|
||||
adminApi.getTemplateStats()
|
||||
])
|
||||
|
||||
setPendingFeatures(pendingData)
|
||||
setNotifications(notificationsData)
|
||||
setStats(statsData)
|
||||
setPendingFeatures(featuresResponse || [])
|
||||
setCustomTemplates(templatesResponse || [])
|
||||
setStats(featureStatsResponse)
|
||||
setTemplateStats(templateStatsResponse)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load dashboard data')
|
||||
console.error('Error loading dashboard data:', err)
|
||||
@ -63,12 +86,14 @@ export function AdminDashboard() {
|
||||
}, [])
|
||||
|
||||
// Handle feature review
|
||||
const handleFeatureReview = async (featureId: string, reviewData: any) => {
|
||||
const handleFeatureReview = async (featureId: string, reviewData: { status: 'approved' | 'rejected' | 'duplicate'; admin_notes?: string; admin_reviewed_by: string }) => {
|
||||
try {
|
||||
await adminApi.reviewFeature(featureId, reviewData)
|
||||
|
||||
// Remove the reviewed feature from pending list
|
||||
setPendingFeatures(prev => prev.filter(f => f.id !== featureId))
|
||||
// Update the feature in the list
|
||||
setPendingFeatures(prev => prev.map(f =>
|
||||
f.id === featureId ? { ...f, status: reviewData.status as AdminFeature['status'], admin_notes: reviewData.admin_notes } : f
|
||||
))
|
||||
|
||||
// Reload stats
|
||||
const newStats = await adminApi.getFeatureStats()
|
||||
@ -79,6 +104,141 @@ export function AdminDashboard() {
|
||||
} catch (err) {
|
||||
console.error('Error reviewing feature:', err)
|
||||
// Handle error (show toast notification, etc.)
|
||||
alert('Error reviewing feature')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle template review
|
||||
const handleTemplateReview = async (templateId: string, reviewData: { status: 'pending' | 'approved' | 'rejected' | 'duplicate'; admin_notes?: string }) => {
|
||||
try {
|
||||
await adminApi.reviewTemplate(templateId, reviewData)
|
||||
|
||||
// Update the template in the list
|
||||
setCustomTemplates(prev => prev.map(t =>
|
||||
t.id === templateId ? { ...t, status: reviewData.status as AdminTemplate['status'], admin_notes: reviewData.admin_notes } : t
|
||||
))
|
||||
|
||||
// Reload stats
|
||||
const newStats = await adminApi.getTemplateStats()
|
||||
setTemplateStats(newStats)
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error reviewing template:', err)
|
||||
// Handle error (show toast notification, etc.)
|
||||
alert('Error reviewing template')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle feature update
|
||||
const handleFeatureUpdate = (featureId: string, updatedFeature: AdminFeature) => {
|
||||
setPendingFeatures(prev => prev.map(f =>
|
||||
f.id === featureId ? updatedFeature : f
|
||||
))
|
||||
}
|
||||
|
||||
// Handle template update
|
||||
const handleTemplateUpdate = (templateId: string, updatedTemplate: AdminTemplate) => {
|
||||
// Hide the edited item from the pending list immediately
|
||||
setCustomTemplates(prev => prev.filter(t => t.id !== templateId))
|
||||
}
|
||||
|
||||
// Handle reject action
|
||||
const handleReject = async (adminNotes: string) => {
|
||||
if (!rejectItem) return
|
||||
|
||||
try {
|
||||
if (rejectItem.type === 'feature') {
|
||||
await adminApi.rejectCustomFeature(rejectItem.id, adminNotes)
|
||||
setPendingFeatures(prev => prev.map(f =>
|
||||
f.id === rejectItem.id ? { ...f, status: 'rejected', admin_notes: adminNotes } : f
|
||||
))
|
||||
// Reload feature stats
|
||||
const newStats = await adminApi.getCustomFeatureStats()
|
||||
setStats(newStats)
|
||||
} else {
|
||||
await adminApi.rejectTemplate(rejectItem.id, adminNotes)
|
||||
setCustomTemplates(prev => prev.map(t =>
|
||||
t.id === rejectItem.id ? { ...t, status: 'rejected', admin_notes: adminNotes } : t
|
||||
))
|
||||
// Reload template stats
|
||||
const newStats = await adminApi.getTemplateStats()
|
||||
setTemplateStats(newStats)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error rejecting ${rejectItem.type}:`, err)
|
||||
// Handle error (show toast notification, etc.)
|
||||
alert(`Error rejecting ${rejectItem.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle approve action
|
||||
const handleApprove = async (item: { id: string; type: 'feature' | 'template' }) => {
|
||||
// Optimistic UI: remove immediately
|
||||
if (item.type === 'feature') {
|
||||
setPendingFeatures(prev => prev.filter(f => f.id !== item.id))
|
||||
} else {
|
||||
setCustomTemplates(prev => prev.filter(t => t.id !== item.id))
|
||||
}
|
||||
try {
|
||||
if (item.type === 'feature') {
|
||||
// Get the feature details first
|
||||
const feature = pendingFeatures.find(f => f.id === item.id)
|
||||
if (feature) {
|
||||
// Create new approved feature in main template_features table
|
||||
await adminApi.createApprovedFeature({
|
||||
template_id: feature.template_id,
|
||||
name: feature.name,
|
||||
description: feature.description,
|
||||
complexity: feature.complexity,
|
||||
business_rules: feature.business_rules,
|
||||
technical_requirements: feature.technical_requirements
|
||||
})
|
||||
|
||||
// Update the custom feature status to approved
|
||||
await adminApi.reviewCustomFeature(item.id, { status: 'approved', admin_notes: 'Approved and created in main templates' })
|
||||
|
||||
// already optimistically removed
|
||||
// Remove related notifications for this feature
|
||||
removeByReference('custom_feature', item.id)
|
||||
}
|
||||
|
||||
// Reload feature stats
|
||||
const newStats = await adminApi.getCustomFeatureStats()
|
||||
setStats(newStats)
|
||||
} else {
|
||||
// Get the template details first
|
||||
const template = customTemplates.find(t => t.id === item.id)
|
||||
if (template) {
|
||||
// Create new approved template in main templates table
|
||||
await adminApi.createApprovedTemplate(item.id, {
|
||||
title: template.title || '',
|
||||
description: template.description,
|
||||
category: template.category || '',
|
||||
type: template.type || '',
|
||||
icon: template.icon,
|
||||
gradient: template.gradient,
|
||||
border: template.border,
|
||||
text: template.text,
|
||||
subtext: template.subtext
|
||||
})
|
||||
|
||||
// Update the custom template status to approved
|
||||
await adminApi.reviewTemplate(item.id, { status: 'approved', admin_notes: 'Approved and created in main templates' })
|
||||
|
||||
// already optimistically removed
|
||||
// Remove related notifications for this template
|
||||
removeByReference('custom_template', item.id)
|
||||
}
|
||||
|
||||
// Reload template stats
|
||||
const newStats = await adminApi.getTemplateStats()
|
||||
setTemplateStats(newStats)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error approving ${item.type}:`, err)
|
||||
// Handle error (show toast notification, etc.)
|
||||
// Recover UI by reloading if optimistic removal was wrong
|
||||
await loadDashboardData()
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,15 +253,17 @@ export function AdminDashboard() {
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
// Get status counts
|
||||
const getStatusCount = (status: string) => {
|
||||
return stats?.features.find(s => s.status === status)?.count || 0
|
||||
// Get status counts for features
|
||||
const getFeatureStatusCount = (status: string) => {
|
||||
return stats?.features?.find(s => s.status === status)?.count || 0
|
||||
}
|
||||
|
||||
const getUnreadNotificationCount = () => {
|
||||
return stats?.notifications.unread || 0
|
||||
// Get status counts for templates
|
||||
const getTemplateStatusCount = (status: string) => {
|
||||
return (templateStats as AdminStats & { templates?: Array<{ status: string; count: number }> })?.templates?.find((s) => s.status === status)?.count || 0
|
||||
}
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@ -133,29 +295,16 @@ export function AdminDashboard() {
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
const renderDashboardOverview = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
|
||||
<p className="text-gray-600">Manage feature approvals and system notifications</p>
|
||||
<h1 className="text-3xl font-bold text-white">Admin Dashboard</h1>
|
||||
<p className="text-white/70">Manage feature approvals and system notifications</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowNotifications(true)}
|
||||
className="relative"
|
||||
>
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
Notifications
|
||||
{getUnreadNotificationCount() > 0 && (
|
||||
<Badge className="ml-2 bg-red-500 text-white">
|
||||
{getUnreadNotificationCount()}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={loadDashboardData}>
|
||||
<Button onClick={loadDashboardData} className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
@ -163,70 +312,166 @@ export function AdminDashboard() {
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-5 w-5 text-yellow-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Pending</p>
|
||||
<p className="text-2xl font-bold">{getStatusCount('pending')}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Features Stats */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-white">Custom Features</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-yellow-600" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Pending</p>
|
||||
<p className="text-xl font-bold text-white">{getFeatureStatusCount('pending')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Approved</p>
|
||||
<p className="text-xl font-bold text-white">{getFeatureStatusCount('approved')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Rejected</p>
|
||||
<p className="text-xl font-bold text-white">{getFeatureStatusCount('rejected')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Copy className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Duplicates</p>
|
||||
<p className="text-xl font-bold text-white">{getFeatureStatusCount('duplicate')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Approved</p>
|
||||
<p className="text-2xl font-bold">{getStatusCount('approved')}</p>
|
||||
{/* Templates Stats */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-white">Custom Templates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-yellow-600" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Pending</p>
|
||||
<p className="text-xl font-bold text-white">{getTemplateStatusCount('pending')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Rejected</p>
|
||||
<p className="text-2xl font-bold">{getStatusCount('rejected')}</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Approved</p>
|
||||
<p className="text-xl font-bold text-white">{getTemplateStatusCount('approved')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Copy className="h-5 w-5 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Duplicates</p>
|
||||
<p className="text-2xl font-bold">{getStatusCount('duplicate')}</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Rejected</p>
|
||||
<p className="text-xl font-bold text-white">{getTemplateStatusCount('rejected')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Copy className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Duplicates</p>
|
||||
<p className="text-xl font-bold text-white">{getTemplateStatusCount('duplicate')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs defaultValue="pending" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="pending" className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Pending Review ({pendingFeatures.length})</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="all" className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>All Features</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Quick Actions */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-white">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
|
||||
onClick={() => window.location.href = '/admin?tab=features'}
|
||||
>
|
||||
<Zap className="h-6 w-6" />
|
||||
<span className="text-sm">Review Features</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
|
||||
onClick={() => window.location.href = '/admin?tab=templates'}
|
||||
>
|
||||
<Files className="h-6 w-6" />
|
||||
<span className="text-sm">Review Templates</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
|
||||
onClick={() => setShowNotifications(true)}
|
||||
>
|
||||
<Bell className="h-6 w-6" />
|
||||
<span className="text-sm">Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="bg-red-500 text-white text-xs">{unreadCount}</Badge>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
<BarChart3 className="h-6 w-6" />
|
||||
<span className="text-sm">Analytics</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{activeTab === 'dashboard' && renderDashboardOverview()}
|
||||
|
||||
{(activeTab === 'features' || activeTab === 'templates') && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{activeTab === 'features' ? 'Custom Features' : 'Custom Templates'}
|
||||
</h1>
|
||||
<p className="text-white/70">
|
||||
{activeTab === 'features'
|
||||
? 'Review and manage custom feature requests'
|
||||
: 'Review and manage custom template submissions'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button onClick={loadDashboardData} className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Main Content - Only show for features/templates tabs */}
|
||||
{activeTab === 'features' && (
|
||||
<div className="space-y-4">
|
||||
|
||||
<TabsContent value="pending" className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1">
|
||||
@ -304,15 +549,38 @@ export function AdminDashboard() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleApprove({ id: feature.id, type: 'feature' })}
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRejectItem({ id: feature.id, name: feature.name, type: 'feature' })
|
||||
setShowRejectDialog(true)
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedFeature(feature)
|
||||
setShowReviewDialog(true)
|
||||
setShowFeatureEditDialog(true)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
Review
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -321,40 +589,38 @@ export function AdminDashboard() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TabsContent value="all" className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
View and manage all features across different statuses. Use the filters above to narrow down results.
|
||||
</p>
|
||||
{/* TODO: Implement all features view with pagination */}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Review Dialog */}
|
||||
{/* Feature Edit Dialog */}
|
||||
{selectedFeature && (
|
||||
<FeatureReviewDialog
|
||||
<FeatureEditDialog
|
||||
feature={selectedFeature}
|
||||
open={showReviewDialog}
|
||||
onOpenChange={setShowReviewDialog}
|
||||
onReview={handleFeatureReview}
|
||||
open={showFeatureEditDialog}
|
||||
onOpenChange={setShowFeatureEditDialog}
|
||||
onUpdate={handleFeatureUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notifications Panel */}
|
||||
<AdminNotificationsPanel
|
||||
open={showNotifications}
|
||||
onOpenChange={setShowNotifications}
|
||||
notifications={notifications}
|
||||
onNotificationRead={async (id) => {
|
||||
try {
|
||||
await adminApi.markNotificationAsRead(id)
|
||||
setNotifications(prev => prev.filter(n => n.id !== id))
|
||||
} catch (err) {
|
||||
console.error('Error marking notification as read:', err)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Reject Dialog */}
|
||||
{rejectItem && (
|
||||
<RejectDialog
|
||||
open={showRejectDialog}
|
||||
onOpenChange={setShowRejectDialog}
|
||||
onReject={handleReject}
|
||||
title={`Reject ${rejectItem.type === 'feature' ? 'Feature' : 'Template'}`}
|
||||
itemName={rejectItem.name}
|
||||
itemType={rejectItem.type}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminDashboard() {
|
||||
return (
|
||||
<AdminNotificationProvider>
|
||||
<AdminDashboardContent />
|
||||
</AdminNotificationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
305
src/components/admin/admin-feature-selection.tsx
Normal file
305
src/components/admin/admin-feature-selection.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { ArrowLeft, Plus, Edit, Trash2 } from 'lucide-react'
|
||||
import { DatabaseTemplate, TemplateFeature } from '@/lib/template-service'
|
||||
import { AICustomFeatureCreator } from '@/components/ai/AICustomFeatureCreator'
|
||||
import { getApiUrl } from '@/config/backend'
|
||||
|
||||
interface AdminFeatureSelectionProps {
|
||||
template: {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
type: string
|
||||
category?: string
|
||||
icon?: string
|
||||
gradient?: string
|
||||
border?: string
|
||||
text?: string
|
||||
subtext?: string
|
||||
}
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function AdminFeatureSelection({ template, onBack }: AdminFeatureSelectionProps) {
|
||||
// Admin template service functions using admin API endpoints
|
||||
const fetchFeatures = async (templateId: string): Promise<TemplateFeature[]> => {
|
||||
const response = await fetch(getApiUrl(`api/templates/${templateId}/features`))
|
||||
if (!response.ok) throw new Error('Failed to fetch features')
|
||||
const data = await response.json()
|
||||
// Handle different response structures
|
||||
return Array.isArray(data) ? data : (data.data || data.features || [])
|
||||
}
|
||||
|
||||
const createFeature = async (templateId: string, feature: Partial<TemplateFeature>) => {
|
||||
const response = await fetch(getApiUrl('api/features'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...feature, template_id: templateId })
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to create feature')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const updateFeature = async (templateId: string, featureId: string, feature: Partial<TemplateFeature>) => {
|
||||
const response = await fetch(getApiUrl(`api/templates/${templateId}/features/${featureId}`), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(feature)
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to update feature')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const deleteFeature = async (templateId: string, featureId: string) => {
|
||||
const response = await fetch(getApiUrl(`api/templates/${templateId}/features/${featureId}`), {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to delete feature')
|
||||
}
|
||||
|
||||
const bulkCreateFeatures = async (templateId: string, features: Partial<TemplateFeature>[]) => {
|
||||
const response = await fetch(getApiUrl(`api/templates/${templateId}/features/bulk`), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ features })
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to create features')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const [features, setFeatures] = useState<TemplateFeature[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [newFeature, setNewFeature] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
complexity: 'medium' as 'low' | 'medium' | 'high'
|
||||
})
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showAIModal, setShowAIModal] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await fetchFeatures(template.id)
|
||||
// Ensure we always have an array
|
||||
setFeatures(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
console.error('Error loading features:', err)
|
||||
const message = err instanceof Error ? err.message : 'Failed to load features'
|
||||
setError(message)
|
||||
setFeatures([]) // Reset to empty array on error
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [template.id])
|
||||
|
||||
const handleAddCustom = async () => {
|
||||
if (!newFeature.name.trim()) return
|
||||
|
||||
// Check if template ID is valid UUID format (database templates use UUIDs)
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
if (!uuidRegex.test(template.id)) {
|
||||
alert(`Cannot add features to demo templates. Template ID: ${template.id} is not a valid UUID. Please select a real template from your database.`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createFeature(template.id, {
|
||||
name: newFeature.name,
|
||||
description: newFeature.description,
|
||||
feature_type: 'essential',
|
||||
complexity: newFeature.complexity,
|
||||
is_default: true,
|
||||
created_by_user: true,
|
||||
})
|
||||
setNewFeature({ name: '', description: '', complexity: 'medium' })
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (created?.id) next.add(created.id)
|
||||
return next
|
||||
})
|
||||
await load()
|
||||
} catch (error) {
|
||||
console.error('Error creating custom feature:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Template ID not found in database'
|
||||
alert(`Failed to create custom feature: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddAIAnalyzed = async (payload: { name: string; description: string; complexity: 'low' | 'medium' | 'high' }) => {
|
||||
// Check if template ID is valid UUID format
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
if (!uuidRegex.test(template.id)) {
|
||||
alert("Cannot add features to demo templates. Please select a real template from your database.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createFeature(template.id, {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
feature_type: 'essential',
|
||||
complexity: payload.complexity,
|
||||
is_default: true,
|
||||
created_by_user: true,
|
||||
})
|
||||
await load()
|
||||
} catch (error) {
|
||||
console.error('Error creating AI-analyzed feature:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Template ID not found in database'
|
||||
alert(`Failed to create AI-analyzed feature: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (f: TemplateFeature, updates: Partial<TemplateFeature>) => {
|
||||
try {
|
||||
await updateFeature(template.id, f.id, updates)
|
||||
await load()
|
||||
} catch (err) {
|
||||
console.error('Error updating feature:', err)
|
||||
const message = err instanceof Error ? err.message : 'Failed to update feature'
|
||||
alert(`Error updating feature: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (f: TemplateFeature) => {
|
||||
try {
|
||||
await deleteFeature(template.id, f.id)
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(f.id)
|
||||
return next
|
||||
})
|
||||
await load()
|
||||
} catch (err) {
|
||||
console.error('Error deleting feature:', err)
|
||||
const message = err instanceof Error ? err.message : 'Failed to delete feature'
|
||||
alert(`Error deleting feature: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (f: TemplateFeature) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(f.id)) next.delete(f.id)
|
||||
else next.add(f.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-20 text-white/60">Loading features...</div>
|
||||
}
|
||||
if (error) {
|
||||
return <div className="text-center py-20 text-red-400">{error}</div>
|
||||
}
|
||||
|
||||
// Ensure features is always an array before filtering
|
||||
const safeFeatures = Array.isArray(features) ? features : []
|
||||
const essentialFeatures = safeFeatures.filter(f => f.feature_type === 'essential')
|
||||
const suggestedFeatures = safeFeatures.filter(f => f.feature_type === 'suggested')
|
||||
const customFeatures = safeFeatures.filter(f => f.feature_type === 'custom')
|
||||
|
||||
const section = (title: string, list: TemplateFeature[]) => (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-3">{title} ({list.length})</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{list.map((f) => (
|
||||
<Card key={f.id} className={`bg-white/5 ${selectedIds.has(f.id) ? 'border-orange-400' : 'border-white/10'}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(f.id)}
|
||||
onCheckedChange={() => toggleSelect(f)}
|
||||
className="border-white/20 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
|
||||
/>
|
||||
<span>{f.name}</span>
|
||||
</div>
|
||||
{f.feature_type === 'custom' && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-blue-500 text-blue-300 hover:bg-blue-500/10"
|
||||
onClick={() => {/* Edit functionality */}}
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500 text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleDelete(f)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-white/80 text-sm space-y-2">
|
||||
<p>{f.description || 'No description provided.'}</p>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<Badge variant="outline" className="bg-white/5 border-white/10">{f.feature_type}</Badge>
|
||||
<Badge variant="outline" className="bg-white/5 border-white/10">{f.complexity}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold text-white">Select Features for {template.title}</h1>
|
||||
<p className="text-xl text-white/60 max-w-3xl mx-auto">Choose defaults or add your own essential features.</p>
|
||||
</div>
|
||||
|
||||
{section('Essential Features', essentialFeatures)}
|
||||
{section('Suggested Features', suggestedFeatures)}
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 space-y-3">
|
||||
<h3 className="text-white font-semibold">Add Essential Feature</h3>
|
||||
<p className="text-white/60 text-sm">Use AI to analyze and create essential features for your project.</p>
|
||||
<div className="text-center">
|
||||
<Button variant="outline" onClick={() => setShowAIModal(true)} className="border-orange-500 text-orange-400 hover:bg-orange-500/10 cursor-pointer">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Analyze with AI
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-white/60 text-xs text-center">AI will analyze your requirements and create essential features</p>
|
||||
</div>
|
||||
|
||||
{section('Your Custom Features', customFeatures)}
|
||||
|
||||
{showAIModal && (
|
||||
<AICustomFeatureCreator
|
||||
projectType={template.type || template.title}
|
||||
onAdd={async (f) => { await handleAddAIAnalyzed(f); setShowAIModal(false) }}
|
||||
onClose={() => setShowAIModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-center py-4">
|
||||
<div className="space-x-4">
|
||||
<Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10 cursor-pointer">Back</Button>
|
||||
</div>
|
||||
<div className="text-white/60 text-sm mt-2">Selected: {selectedIds.size} | Essential: {essentialFeatures.length} | Suggested: {suggestedFeatures.length} | Custom: {customFeatures.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -14,34 +14,38 @@ import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Bell,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Copy,
|
||||
Clock,
|
||||
Check,
|
||||
Trash2
|
||||
Trash2,
|
||||
Wifi,
|
||||
WifiOff
|
||||
} from 'lucide-react'
|
||||
import { AdminNotification } from '@/types/admin.types'
|
||||
import { formatDate } from '@/lib/api/admin'
|
||||
import { useAdminNotifications } from '@/contexts/AdminNotificationContext'
|
||||
|
||||
interface AdminNotificationsPanelProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
notifications: AdminNotification[]
|
||||
onNotificationRead: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function AdminNotificationsPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
notifications,
|
||||
onNotificationRead
|
||||
onOpenChange
|
||||
}: AdminNotificationsPanelProps) {
|
||||
const [markingAsRead, setMarkingAsRead] = useState<string | null>(null)
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isConnected,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
clearAll
|
||||
} = useAdminNotifications()
|
||||
|
||||
const handleMarkAsRead = async (id: string) => {
|
||||
try {
|
||||
setMarkingAsRead(id)
|
||||
await onNotificationRead(id)
|
||||
await markAsRead(id)
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as read:', error)
|
||||
} finally {
|
||||
@ -49,6 +53,22 @@ export function AdminNotificationsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
try {
|
||||
await markAllAsRead()
|
||||
} catch (error) {
|
||||
console.error('Error marking all notifications as read:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await clearAll()
|
||||
} catch (error) {
|
||||
console.error('Error clearing all notifications:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'new_feature':
|
||||
@ -81,14 +101,27 @@ export function AdminNotificationsPanel({
|
||||
<SheetTitle className="flex items-center space-x-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span>Admin Notifications</span>
|
||||
{unreadNotifications.length > 0 && (
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="bg-red-500 text-white">
|
||||
{unreadNotifications.length}
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center ml-auto">
|
||||
{isConnected ? (
|
||||
<div className="flex items-center text-green-600">
|
||||
<Wifi className="h-4 w-4 mr-1" />
|
||||
<span className="text-xs">Live</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-red-600">
|
||||
<WifiOff className="h-4 w-4 mr-1" />
|
||||
<span className="text-xs">Offline</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
System notifications and feature review updates
|
||||
System notifications and feature review updates • Real-time updates {isConnected ? 'enabled' : 'disabled'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@ -187,7 +220,7 @@ export function AdminNotificationsPanel({
|
||||
<Bell className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500">No notifications</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
You're all caught up! New notifications will appear here.
|
||||
You're all caught up! New notifications will appear here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -203,14 +236,20 @@ export function AdminNotificationsPanel({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// TODO: Implement mark all as read
|
||||
console.log('Mark all as read')
|
||||
}}
|
||||
onClick={handleMarkAllAsRead}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Mark All Read
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="ml-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
657
src/components/admin/admin-templates-list.tsx
Normal file
657
src/components/admin/admin-templates-list.tsx
Normal file
@ -0,0 +1,657 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import {
|
||||
Search,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Zap,
|
||||
Globe,
|
||||
BarChart3,
|
||||
Code,
|
||||
ShoppingCart,
|
||||
Briefcase,
|
||||
GraduationCap,
|
||||
Plus,
|
||||
Save,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { adminApi, formatDate, getComplexityColor } from '@/lib/api/admin'
|
||||
import { AdminTemplate, AdminStats } from '@/types/admin.types'
|
||||
import { AdminFeatureSelection } from './admin-feature-selection'
|
||||
|
||||
interface AdminTemplatesListProps {
|
||||
onTemplateSelect?: (template: AdminTemplate) => void
|
||||
}
|
||||
|
||||
export function AdminTemplatesList({ onTemplateSelect }: AdminTemplatesListProps) {
|
||||
const [templates, setTemplates] = useState<AdminTemplate[]>([])
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [showFeaturesManager, setShowFeaturesManager] = useState(false)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<any | null>(null)
|
||||
const [showFeatureSelection, setShowFeatureSelection] = useState(false)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
// Create template form state
|
||||
const [newTemplate, setNewTemplate] = useState({
|
||||
type: '',
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
icon: '',
|
||||
gradient: '',
|
||||
border: '',
|
||||
text: '',
|
||||
subtext: ''
|
||||
})
|
||||
|
||||
const categories = [
|
||||
"Food Delivery",
|
||||
"E-commerce",
|
||||
"SaaS Platform",
|
||||
"Mobile App",
|
||||
"Dashboard",
|
||||
"CRM System",
|
||||
"Learning Platform",
|
||||
"Healthcare",
|
||||
"Real Estate",
|
||||
"Travel",
|
||||
"Entertainment",
|
||||
"Finance",
|
||||
"Social Media",
|
||||
"Marketplace",
|
||||
"Other"
|
||||
]
|
||||
|
||||
// Load templates and stats
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
console.log('Loading admin templates data...')
|
||||
|
||||
const [templatesResponse, statsResponse] = await Promise.all([
|
||||
adminApi.getAdminTemplates(50, 0, categoryFilter, searchQuery),
|
||||
adminApi.getAdminTemplateStats()
|
||||
])
|
||||
|
||||
console.log('Admin templates response:', templatesResponse)
|
||||
console.log('Admin template stats response:', statsResponse)
|
||||
|
||||
setTemplates(templatesResponse || [])
|
||||
setStats(statsResponse)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load admin templates')
|
||||
console.error('Error loading admin templates:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [categoryFilter, searchQuery])
|
||||
|
||||
// Handle template selection for features
|
||||
const handleManageFeatures = (template: AdminTemplate) => {
|
||||
// Convert AdminTemplate to Template format for feature management
|
||||
const templateForFeatures = {
|
||||
id: template.id,
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
type: template.type,
|
||||
category: template.category,
|
||||
icon: template.icon,
|
||||
gradient: template.gradient,
|
||||
border: template.border,
|
||||
text: template.text,
|
||||
subtext: template.subtext
|
||||
}
|
||||
setSelectedTemplate(templateForFeatures)
|
||||
setShowFeatureSelection(true)
|
||||
}
|
||||
|
||||
// Handle create template form submission
|
||||
const handleCreateTemplate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// Create template payload
|
||||
const templateData = {
|
||||
...newTemplate,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
// Call API to create template (you'll need to implement this endpoint)
|
||||
const response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create template')
|
||||
}
|
||||
|
||||
// Reset form and hide it
|
||||
setNewTemplate({
|
||||
type: '',
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
icon: '',
|
||||
gradient: '',
|
||||
border: '',
|
||||
text: '',
|
||||
subtext: ''
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
|
||||
// Reload data to show new template
|
||||
await loadData()
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating template:', error)
|
||||
setError(error instanceof Error ? error.message : 'Failed to create template')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Get category icon
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category.toLowerCase()) {
|
||||
case 'marketing':
|
||||
return Zap
|
||||
case 'software':
|
||||
return Code
|
||||
case 'seo':
|
||||
return BarChart3
|
||||
case 'ecommerce':
|
||||
return ShoppingCart
|
||||
case 'portfolio':
|
||||
return Briefcase
|
||||
case 'business':
|
||||
return Briefcase
|
||||
case 'education':
|
||||
return GraduationCap
|
||||
default:
|
||||
return Globe
|
||||
}
|
||||
}
|
||||
|
||||
// Get category stats for filters
|
||||
const getCategoryStats = () => {
|
||||
const categoryStats = [
|
||||
{ id: 'all', name: 'All Templates', count: templates.length, icon: Globe },
|
||||
{ id: 'marketing', name: 'Marketing', count: 0, icon: Zap },
|
||||
{ id: 'software', name: 'Software', count: 0, icon: Code },
|
||||
{ id: 'seo', name: 'SEO', count: 0, icon: BarChart3 },
|
||||
{ id: 'ecommerce', name: 'E-commerce', count: 0, icon: ShoppingCart },
|
||||
{ id: 'portfolio', name: 'Portfolio', count: 0, icon: Briefcase },
|
||||
{ id: 'business', name: 'Business', count: 0, icon: Briefcase },
|
||||
{ id: 'education', name: 'Education', count: 0, icon: GraduationCap }
|
||||
]
|
||||
|
||||
// Count templates by category
|
||||
templates.forEach(template => {
|
||||
const categoryId = template.category?.toLowerCase()
|
||||
const categoryItem = categoryStats.find(cat => cat.id === categoryId)
|
||||
if (categoryItem) {
|
||||
categoryItem.count++
|
||||
}
|
||||
})
|
||||
|
||||
return categoryStats
|
||||
}
|
||||
|
||||
// Filter templates based on search
|
||||
const filteredTemplates = templates.filter(template => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
template.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.type?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
return matchesSearch
|
||||
})
|
||||
|
||||
const TemplateCard = ({ template }: { template: AdminTemplate }) => (
|
||||
<Card className="group hover:shadow-md transition-all bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2 flex-1">
|
||||
<CardTitle className="text-lg text-white group-hover:text-orange-400 transition-colors">
|
||||
{template.title}
|
||||
</CardTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="text-xs bg-white/5 border-white/10 text-white/80">
|
||||
{template.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs bg-white/5 border-white/10 text-white/70">
|
||||
{template.category}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleManageFeatures(template)}
|
||||
className="text-green-400 hover:text-green-300 border-green-400/30 hover:border-green-300/50"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
Features
|
||||
</Button>
|
||||
</div>
|
||||
{template.description && (
|
||||
<p className="text-white/70 text-sm">{template.description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 text-white/80">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Zap className="h-4 w-4 text-orange-400" />
|
||||
<span>{(template as any).feature_count || 0} features</span>
|
||||
</div>
|
||||
<div className="text-white/60">
|
||||
{template.created_at && formatDate(template.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{template.gradient && (
|
||||
<div className="text-xs text-white/60">
|
||||
<span className="font-medium">Style:</span> {template.gradient}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-orange-400" />
|
||||
<span className="text-white">Loading admin templates...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Card className="w-full max-w-md bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2 text-red-400">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
<span>Error loading admin templates</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-400">{error}</p>
|
||||
<Button onClick={loadData} className="mt-4 bg-orange-500 text-black hover:bg-orange-600">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show feature selection view if a template is selected
|
||||
if (showFeatureSelection && selectedTemplate) {
|
||||
return (
|
||||
<AdminFeatureSelection
|
||||
template={selectedTemplate}
|
||||
onBack={() => {
|
||||
setShowFeatureSelection(false)
|
||||
setSelectedTemplate(null)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Admin Templates</h1>
|
||||
<p className="text-white/70">Manage features for your templates</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-green-500 text-black hover:bg-green-600"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Template
|
||||
</Button>
|
||||
<Button onClick={loadData} className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Total Templates</CardTitle>
|
||||
<Globe className="h-4 w-4 text-white/60" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{(stats as any).total_templates || templates.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Categories</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-white/60" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{(stats as any).total_categories || categories.length - 1}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Avg Features</CardTitle>
|
||||
<Zap className="h-4 w-4 text-orange-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{Math.round((stats as any).avg_features_per_template || 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">With Features</CardTitle>
|
||||
<Settings className="h-4 w-4 text-green-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{(stats as any).templates_with_features || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search templates..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-48 bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Filter by category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getCategoryStats().map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<category.icon className="h-4 w-4" />
|
||||
<span>{category.name} ({category.count})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{getCategoryStats().map((category) => {
|
||||
const Icon = category.icon
|
||||
const active = categoryFilter === category.id
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setCategoryFilter(category.id)}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg border transition-all ${
|
||||
active
|
||||
? "bg-orange-500 text-black border-orange-500"
|
||||
: "bg-white/5 text-white/80 border-white/10 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="font-medium">{category.name}</span>
|
||||
<Badge variant="secondary" className="ml-1 bg-white/10 text-white">
|
||||
{category.count}
|
||||
</Badge>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Create Template Modal */}
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800 text-white max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Create New Template</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleCreateTemplate} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Template Type *</label>
|
||||
<Input
|
||||
placeholder="e.g., multi_restaurant_food_delivery"
|
||||
value={newTemplate.type}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-white/60">Unique identifier for the template</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Title *</label>
|
||||
<Input
|
||||
placeholder="e.g., Multi-Restaurant Food Delivery App"
|
||||
value={newTemplate.title}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Description *</label>
|
||||
<textarea
|
||||
placeholder="Describe your template and its key features..."
|
||||
value={newTemplate.description}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 text-white placeholder:text-white/40 rounded-md min-h-[100px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Category *</label>
|
||||
<Select value={newTemplate.category} onValueChange={(value) => setNewTemplate(prev => ({ ...prev, category: value }))}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-white/10">
|
||||
{categories.map((category, index) => (
|
||||
<SelectItem key={`category-${index}`} value={category} className="text-white">
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Icon (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., restaurant, shopping-cart, users"
|
||||
value={newTemplate.icon}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, icon: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Gradient (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., from-orange-400 to-red-500"
|
||||
value={newTemplate.gradient}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, gradient: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Border (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., border-orange-500"
|
||||
value={newTemplate.border}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, border: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Text Color (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., text-orange-500"
|
||||
value={newTemplate.text}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, text: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Subtext (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., Perfect for food delivery startups"
|
||||
value={newTemplate.subtext}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, subtext: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="border-white/20 text-white hover:bg-white/10 cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? "Creating..." : "Create Template"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Feature Selection View */}
|
||||
{showFeatureSelection && selectedTemplate && (
|
||||
<AdminFeatureSelection
|
||||
template={selectedTemplate}
|
||||
onBack={() => {
|
||||
setShowFeatureSelection(false)
|
||||
setSelectedTemplate(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Features Manager Modal */}
|
||||
{showFeaturesManager && selectedTemplate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-gray-900 rounded-lg max-w-6xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-white">Manage Features - {selectedTemplate.title}</h2>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowFeaturesManager(false)
|
||||
setSelectedTemplate(null)
|
||||
}}
|
||||
variant="outline"
|
||||
className="text-white border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<AdminFeatureSelection
|
||||
template={selectedTemplate}
|
||||
onBack={() => {
|
||||
setShowFeaturesManager(false)
|
||||
setSelectedTemplate(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates Grid */}
|
||||
<div className="space-y-4">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-gray-400">
|
||||
<Globe className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<p>No templates found</p>
|
||||
<p className="text-sm">Try adjusting your search or filters.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredTemplates.map((template) => (
|
||||
<TemplateCard key={template.id} template={template} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
647
src/components/admin/admin-templates-manager.tsx
Normal file
647
src/components/admin/admin-templates-manager.tsx
Normal file
@ -0,0 +1,647 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Filter,
|
||||
Search,
|
||||
Edit,
|
||||
Plus,
|
||||
Save,
|
||||
Files,
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin'
|
||||
import { AdminTemplate, AdminStats } from '@/types/admin.types'
|
||||
import { TemplateEditDialog } from './template-edit-dialog'
|
||||
import { RejectDialog } from './reject-dialog'
|
||||
import { TemplateFeaturesManager } from './template-features-manager'
|
||||
import { AdminTemplatesList } from './admin-templates-list'
|
||||
import { useAdminNotifications } from '@/contexts/AdminNotificationContext'
|
||||
|
||||
export function AdminTemplatesManager() {
|
||||
const [activeTab, setActiveTab] = useState("admin-templates")
|
||||
const [customTemplates, setCustomTemplates] = useState<AdminTemplate[]>([])
|
||||
const [templateStats, setTemplateStats] = useState<AdminStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<AdminTemplate | null>(null)
|
||||
const [showTemplateEditDialog, setShowTemplateEditDialog] = useState(false)
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false)
|
||||
const [rejectItem, setRejectItem] = useState<{ id: string; name: string; type: 'template' } | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [showFeaturesManager, setShowFeaturesManager] = useState(false)
|
||||
const [selectedTemplateForFeatures, setSelectedTemplateForFeatures] = useState<AdminTemplate | null>(null)
|
||||
|
||||
// Create template form state
|
||||
const [newTemplate, setNewTemplate] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
type: '',
|
||||
complexity: 'low',
|
||||
icon: '',
|
||||
gradient: '',
|
||||
border: '',
|
||||
text: '',
|
||||
subtext: ''
|
||||
})
|
||||
|
||||
const { removeByReference } = useAdminNotifications()
|
||||
|
||||
// Load templates data
|
||||
const loadTemplatesData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
console.log('Loading templates data...')
|
||||
|
||||
const [templatesResponse, templateStatsResponse] = await Promise.all([
|
||||
adminApi.getCustomTemplates(), // Try without status filter first
|
||||
adminApi.getTemplateStats()
|
||||
])
|
||||
|
||||
console.log('Templates response:', templatesResponse)
|
||||
console.log('Template stats response:', templateStatsResponse)
|
||||
|
||||
setCustomTemplates(templatesResponse || [])
|
||||
setTemplateStats(templateStatsResponse)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load templates data')
|
||||
console.error('Error loading templates data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplatesData()
|
||||
}, [])
|
||||
|
||||
// Handle template review
|
||||
const handleTemplateReview = async (templateId: string, reviewData: { status: 'pending' | 'approved' | 'rejected' | 'duplicate'; admin_notes?: string }) => {
|
||||
try {
|
||||
await adminApi.reviewTemplate(templateId, reviewData)
|
||||
|
||||
// Update the template in the list
|
||||
setCustomTemplates(prev => prev.map(t =>
|
||||
t.id === templateId ? { ...t, status: reviewData.status as AdminTemplate['status'], admin_notes: reviewData.admin_notes } : t
|
||||
))
|
||||
|
||||
// Reload stats
|
||||
const newStats = await adminApi.getTemplateStats()
|
||||
setTemplateStats(newStats)
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error reviewing template:', err)
|
||||
alert('Error reviewing template')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle template update
|
||||
const handleTemplateUpdate = (templateId: string, updatedTemplate: AdminTemplate) => {
|
||||
setCustomTemplates(prev => prev.filter(t => t.id !== templateId))
|
||||
}
|
||||
|
||||
// Handle reject action
|
||||
const handleReject = async (adminNotes: string) => {
|
||||
if (!rejectItem) return
|
||||
|
||||
try {
|
||||
await adminApi.rejectTemplate(rejectItem.id, adminNotes)
|
||||
setCustomTemplates(prev => prev.map(t =>
|
||||
t.id === rejectItem.id ? { ...t, status: 'rejected', admin_notes: adminNotes } : t
|
||||
))
|
||||
// Reload template stats
|
||||
const newStats = await adminApi.getTemplateStats()
|
||||
setTemplateStats(newStats)
|
||||
} catch (err) {
|
||||
console.error('Error rejecting template:', err)
|
||||
alert('Error rejecting template')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle approve action
|
||||
const handleApprove = async (templateId: string) => {
|
||||
// Optimistic UI: remove immediately
|
||||
setCustomTemplates(prev => prev.filter(t => t.id !== templateId))
|
||||
|
||||
try {
|
||||
// Get the template details first
|
||||
const template = customTemplates.find(t => t.id === templateId)
|
||||
if (template) {
|
||||
// Create new approved template in main templates table
|
||||
await adminApi.createApprovedTemplate(templateId, {
|
||||
title: template.title || '',
|
||||
description: template.description,
|
||||
category: template.category || '',
|
||||
type: template.type || '',
|
||||
icon: template.icon,
|
||||
gradient: template.gradient,
|
||||
border: template.border,
|
||||
text: template.text,
|
||||
subtext: template.subtext
|
||||
})
|
||||
|
||||
// Update the custom template status to approved
|
||||
await adminApi.reviewTemplate(templateId, { status: 'approved', admin_notes: 'Approved and created in main templates' })
|
||||
|
||||
// Remove related notifications for this template
|
||||
removeByReference('custom_template', templateId)
|
||||
}
|
||||
|
||||
// Reload template stats
|
||||
const newStats = await adminApi.getTemplateStats()
|
||||
setTemplateStats(newStats)
|
||||
} catch (err) {
|
||||
console.error('Error approving template:', err)
|
||||
// Recover UI by reloading if optimistic removal was wrong
|
||||
await loadTemplatesData()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle create template
|
||||
const handleCreateTemplate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!newTemplate.title || !newTemplate.description || !newTemplate.category || !newTemplate.type) {
|
||||
alert('Please fill in all required fields (Title, Description, Category, Type)')
|
||||
return
|
||||
}
|
||||
|
||||
// Call API to create new custom template using the correct endpoint
|
||||
await adminApi.createCustomTemplate({
|
||||
title: newTemplate.title,
|
||||
description: newTemplate.description,
|
||||
category: newTemplate.category,
|
||||
type: newTemplate.type,
|
||||
complexity: newTemplate.complexity,
|
||||
icon: newTemplate.icon,
|
||||
gradient: newTemplate.gradient,
|
||||
border: newTemplate.border,
|
||||
text: newTemplate.text,
|
||||
subtext: newTemplate.subtext
|
||||
})
|
||||
|
||||
// Reset form
|
||||
setNewTemplate({
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
type: '',
|
||||
complexity: 'low',
|
||||
icon: '',
|
||||
gradient: '',
|
||||
border: '',
|
||||
text: '',
|
||||
subtext: ''
|
||||
})
|
||||
|
||||
// Reload templates
|
||||
await loadTemplatesData()
|
||||
|
||||
// Switch to manage tab
|
||||
setActiveTab("manage")
|
||||
|
||||
alert('Template created successfully!')
|
||||
} catch (err) {
|
||||
console.error('Error creating template:', err)
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create template'
|
||||
alert(`Error creating template: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter templates based on search and status
|
||||
const filteredTemplates = customTemplates.filter(template => {
|
||||
const matchesSearch = template.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.category?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || template.status === statusFilter
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
// Get status counts for templates
|
||||
const getTemplateStatusCount = (status: string) => {
|
||||
return (templateStats as AdminStats & { templates?: Array<{ status: string; count: number }> })?.templates?.find((s) => s.status === status)?.count || 0
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span>Loading templates...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2 text-red-600">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
<span>Error loading templates</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-600">{error}</p>
|
||||
<Button onClick={loadTemplatesData} className="mt-4">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Templates Management</h1>
|
||||
<p className="text-white/70">Manage templates and create new ones</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button onClick={loadTemplatesData} className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Total Templates</CardTitle>
|
||||
<Files className="h-4 w-4 text-white/60" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{customTemplates.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Pending</CardTitle>
|
||||
<Clock className="h-4 w-4 text-yellow-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{getTemplateStatusCount('pending')}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Approved</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{getTemplateStatusCount('approved')}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Rejected</CardTitle>
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{getTemplateStatusCount('rejected')}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<TabsList className="bg-gray-900 border-gray-800">
|
||||
<TabsTrigger value="admin-templates" className="flex items-center space-x-2 data-[state=active]:bg-orange-500 data-[state=active]:text-black text-white/70">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Admin Templates</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manage" className="flex items-center space-x-2 data-[state=active]:bg-orange-500 data-[state=active]:text-black text-white/70">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>Custom Templates ({customTemplates.length})</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="admin-templates" className="space-y-4">
|
||||
<AdminTemplatesList />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manage" className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search templates..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="duplicate">Duplicate</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Templates List */}
|
||||
<div className="space-y-4">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-gray-500">
|
||||
<Copy className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No templates found</p>
|
||||
<p className="text-sm">All templates have been reviewed or no templates match your filters.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredTemplates.map((template) => (
|
||||
<Card key={template.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-lg font-semibold">{template.title}</h3>
|
||||
<Badge className={getStatusColor(template.status)}>
|
||||
{template.status}
|
||||
</Badge>
|
||||
<Badge className={getComplexityColor(template.complexity)}>
|
||||
{template.complexity}
|
||||
</Badge>
|
||||
{template.category && (
|
||||
<Badge variant="outline">{template.category}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{template.description && (
|
||||
<p className="text-gray-600 mb-2">{template.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>Type: {template.type || 'Unknown'}</span>
|
||||
<span>Submitted: {formatDate(template.created_at)}</span>
|
||||
<span>Usage: {template.usage_count || 0}</span>
|
||||
</div>
|
||||
|
||||
{template.admin_notes && (
|
||||
<div className="mt-2 p-2 bg-gray-50 rounded">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Admin Notes:</strong> {template.admin_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleApprove(template.id)}
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRejectItem({ id: template.id, name: template.title, type: 'template' })
|
||||
setShowRejectDialog(true)
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedTemplateForFeatures(template)
|
||||
setShowFeaturesManager(true)
|
||||
}}
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
Features
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedTemplate(template)
|
||||
setShowTemplateEditDialog(true)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="create" className="space-y-4">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Create New Template</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreateTemplate} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title" className="text-white">Template Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newTemplate.title}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="Enter template title"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category" className="text-white">Category</Label>
|
||||
<Select value={newTemplate.category} onValueChange={(value) => setNewTemplate(prev => ({ ...prev, category: value }))}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="marketing">Marketing</SelectItem>
|
||||
<SelectItem value="software">Software</SelectItem>
|
||||
<SelectItem value="seo">SEO</SelectItem>
|
||||
<SelectItem value="ecommerce">E-commerce</SelectItem>
|
||||
<SelectItem value="portfolio">Portfolio</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-white">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={newTemplate.description}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="Enter template description"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-white">Type</Label>
|
||||
<Select value={newTemplate.type} onValueChange={(value) => setNewTemplate(prev => ({ ...prev, type: value }))}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select template type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Business Website">Business Website</SelectItem>
|
||||
<SelectItem value="E-commerce Store">E-commerce Store</SelectItem>
|
||||
<SelectItem value="Landing Page">Landing Page</SelectItem>
|
||||
<SelectItem value="Blog Platform">Blog Platform</SelectItem>
|
||||
<SelectItem value="Portfolio Site">Portfolio Site</SelectItem>
|
||||
<SelectItem value="SaaS Platform">SaaS Platform</SelectItem>
|
||||
<SelectItem value="Mobile App">Mobile App</SelectItem>
|
||||
<SelectItem value="Web Application">Web Application</SelectItem>
|
||||
<SelectItem value="Marketing Site">Marketing Site</SelectItem>
|
||||
<SelectItem value="Corporate Website">Corporate Website</SelectItem>
|
||||
<SelectItem value="Educational Platform">Educational Platform</SelectItem>
|
||||
<SelectItem value="Social Media App">Social Media App</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="complexity" className="text-white">Complexity</Label>
|
||||
<Select value={newTemplate.complexity} onValueChange={(value) => setNewTemplate(prev => ({ ...prev, complexity: value }))}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select complexity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icon" className="text-white">Icon</Label>
|
||||
<Input
|
||||
id="icon"
|
||||
value={newTemplate.icon}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, icon: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="Icon name or URL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gradient" className="text-white">Gradient</Label>
|
||||
<Input
|
||||
id="gradient"
|
||||
value={newTemplate.gradient}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, gradient: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="CSS gradient value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={() => setActiveTab("manage")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Create Template
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Template Edit Dialog */}
|
||||
{selectedTemplate && (
|
||||
<TemplateEditDialog
|
||||
template={selectedTemplate}
|
||||
open={showTemplateEditDialog}
|
||||
onOpenChange={setShowTemplateEditDialog}
|
||||
onUpdate={handleTemplateUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reject Dialog */}
|
||||
{rejectItem && (
|
||||
<RejectDialog
|
||||
open={showRejectDialog}
|
||||
onOpenChange={setShowRejectDialog}
|
||||
onReject={handleReject}
|
||||
title="Reject Template"
|
||||
itemName={rejectItem.name}
|
||||
itemType={rejectItem.type}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Template Features Manager */}
|
||||
{selectedTemplateForFeatures && (
|
||||
<TemplateFeaturesManager
|
||||
template={selectedTemplateForFeatures}
|
||||
open={showFeaturesManager}
|
||||
onOpenChange={setShowFeaturesManager}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
257
src/components/admin/feature-edit-dialog.tsx
Normal file
257
src/components/admin/feature-edit-dialog.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Loader2, Save, AlertTriangle } from 'lucide-react'
|
||||
import { adminApi } from '@/lib/api/admin'
|
||||
import { AdminFeature } from '@/types/admin.types'
|
||||
|
||||
interface FeatureEditDialogProps {
|
||||
feature: AdminFeature
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUpdate: (featureId: string, updatedFeature: AdminFeature) => void
|
||||
}
|
||||
|
||||
export function FeatureEditDialog({
|
||||
feature,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdate
|
||||
}: FeatureEditDialogProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
complexity: 'medium' as 'low' | 'medium' | 'high',
|
||||
business_rules: '',
|
||||
technical_requirements: ''
|
||||
})
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Initialize form data when feature changes
|
||||
useEffect(() => {
|
||||
if (feature) {
|
||||
setFormData({
|
||||
name: feature.name || '',
|
||||
description: feature.description || '',
|
||||
complexity: feature.complexity || 'medium',
|
||||
business_rules: feature.business_rules ? JSON.stringify(feature.business_rules, null, 2) : '',
|
||||
technical_requirements: feature.technical_requirements ? JSON.stringify(feature.technical_requirements, null, 2) : ''
|
||||
})
|
||||
setError(null)
|
||||
}
|
||||
}, [feature])
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
setError('Feature name is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Prepare update data
|
||||
const updateData: Record<string, unknown> = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
complexity: formData.complexity
|
||||
}
|
||||
|
||||
// Parse JSON fields if they have content
|
||||
if (formData.business_rules.trim()) {
|
||||
try {
|
||||
updateData.business_rules = JSON.parse(formData.business_rules)
|
||||
} catch (err) {
|
||||
setError('Invalid JSON format in business rules')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.technical_requirements.trim()) {
|
||||
try {
|
||||
updateData.technical_requirements = JSON.parse(formData.technical_requirements)
|
||||
} catch (err) {
|
||||
setError('Invalid JSON format in technical requirements')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const updatedFeature = await adminApi.updateCustomFeature(feature.id, updateData)
|
||||
|
||||
// Update the feature in the parent component
|
||||
onUpdate(feature.id, { ...feature, ...updatedFeature })
|
||||
|
||||
// Close dialog
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error('Error updating feature:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getComplexityColor = (complexity: string) => {
|
||||
switch (complexity) {
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Custom Feature</DialogTitle>
|
||||
<p className="text-sm text-gray-600">
|
||||
Template: <strong>{feature.template_title || 'Unknown'}</strong>
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Feature Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Feature Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
placeholder="Enter feature name..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
placeholder="Describe what this feature does..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Complexity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="complexity">Complexity</Label>
|
||||
<Select value={formData.complexity} onValueChange={(value) => handleInputChange('complexity', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-green-100 text-green-800">Low</Badge>
|
||||
<span>Simple implementation</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="medium">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-yellow-100 text-yellow-800">Medium</Badge>
|
||||
<span>Moderate complexity</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="high">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-red-100 text-red-800">High</Badge>
|
||||
<span>Complex implementation</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Business Rules */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="business_rules">Business Rules (JSON)</Label>
|
||||
<Textarea
|
||||
id="business_rules"
|
||||
value={formData.business_rules}
|
||||
onChange={(e) => handleInputChange('business_rules', e.target.value)}
|
||||
placeholder='{"rule1": "description", "rule2": "description"}'
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Optional: Define business rules for this feature in JSON format
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Technical Requirements */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="technical_requirements">Technical Requirements (JSON)</Label>
|
||||
<Textarea
|
||||
id="technical_requirements"
|
||||
value={formData.technical_requirements}
|
||||
onChange={(e) => handleInputChange('technical_requirements', e.target.value)}
|
||||
placeholder='{"framework": "React", "database": "PostgreSQL"}'
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Optional: Define technical requirements in JSON format
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !formData.name.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Update Feature
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
140
src/components/admin/reject-dialog.tsx
Normal file
140
src/components/admin/reject-dialog.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Loader2, XCircle, AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface RejectDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onReject: (adminNotes: string) => Promise<void>
|
||||
title: string
|
||||
itemName: string
|
||||
itemType: 'feature' | 'template'
|
||||
}
|
||||
|
||||
export function RejectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onReject,
|
||||
title,
|
||||
itemName,
|
||||
itemType
|
||||
}: RejectDialogProps) {
|
||||
const [adminNotes, setAdminNotes] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!adminNotes.trim()) {
|
||||
setError('Please provide a reason for rejection')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
await onReject(adminNotes.trim())
|
||||
|
||||
// Reset form and close dialog
|
||||
setAdminNotes('')
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : `Failed to reject ${itemType}`)
|
||||
console.error(`Error rejecting ${itemType}:`, error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setAdminNotes('')
|
||||
setError(null)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center text-red-600">
|
||||
<XCircle className="h-5 w-5 mr-2" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-800">
|
||||
You are about to reject: <strong>{itemName}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
This action will mark the {itemType} as rejected and notify the submitter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adminNotes">Reason for Rejection *</Label>
|
||||
<Textarea
|
||||
id="adminNotes"
|
||||
value={adminNotes}
|
||||
onChange={(e) => setAdminNotes(e.target.value)}
|
||||
placeholder={`Explain why this ${itemType} is being rejected...`}
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
This message will be visible to the submitter
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={loading || !adminNotes.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Rejecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Reject {itemType === 'feature' ? 'Feature' : 'Template'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
319
src/components/admin/template-edit-dialog.tsx
Normal file
319
src/components/admin/template-edit-dialog.tsx
Normal file
@ -0,0 +1,319 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Loader2, Save, AlertTriangle } from 'lucide-react'
|
||||
import { adminApi, AdminApiError } from '@/lib/api/admin'
|
||||
import { AdminTemplate } from '@/types/admin.types'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
|
||||
interface TemplateEditDialogProps {
|
||||
template: AdminTemplate
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUpdate: (templateId: string, updatedTemplate: AdminTemplate) => void
|
||||
}
|
||||
|
||||
export function TemplateEditDialog({
|
||||
template,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdate
|
||||
}: TemplateEditDialogProps) {
|
||||
const { show } = useToast()
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
type: '',
|
||||
icon: '',
|
||||
gradient: '',
|
||||
border: '',
|
||||
text: '',
|
||||
subtext: ''
|
||||
})
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [conflictInfo, setConflictInfo] = useState<{ title?: string; type?: string } | null>(null)
|
||||
|
||||
const categories = [
|
||||
"Food Delivery",
|
||||
"E-commerce",
|
||||
"SaaS Platform",
|
||||
"Mobile App",
|
||||
"Dashboard",
|
||||
"CRM System",
|
||||
"Learning Platform",
|
||||
"Healthcare",
|
||||
"Real Estate",
|
||||
"Travel",
|
||||
"Entertainment",
|
||||
"Finance",
|
||||
"Social Media",
|
||||
"Marketplace",
|
||||
"Other"
|
||||
]
|
||||
|
||||
// Initialize form data when template changes
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setFormData({
|
||||
title: template.title || '',
|
||||
description: template.description || '',
|
||||
category: template.category || '',
|
||||
type: template.type || '',
|
||||
icon: template.icon || '',
|
||||
gradient: template.gradient || '',
|
||||
border: template.border || '',
|
||||
text: template.text || '',
|
||||
subtext: template.subtext || ''
|
||||
})
|
||||
setError(null)
|
||||
setConflictInfo(null)
|
||||
}
|
||||
}, [template])
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
setError('Template title is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.type.trim()) {
|
||||
setError('Template type is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setConflictInfo(null)
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
title: formData.title.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
category: formData.category || undefined,
|
||||
type: formData.type.trim(),
|
||||
icon: formData.icon.trim() || undefined,
|
||||
gradient: formData.gradient.trim() || undefined,
|
||||
border: formData.border.trim() || undefined,
|
||||
text: formData.text.trim() || undefined,
|
||||
subtext: formData.subtext.trim() || undefined
|
||||
}
|
||||
|
||||
const newTemplate = await adminApi.createTemplateFromEdit(template.id, updateData)
|
||||
|
||||
// Update the template in the parent component with the new template data
|
||||
onUpdate(template.id, { ...template, ...newTemplate, status: 'pending' })
|
||||
|
||||
// Close dialog
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
if (error instanceof AdminApiError) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data: any = (error as any).data || {}
|
||||
if (data?.existing_template) {
|
||||
setConflictInfo({ title: data.existing_template.title, type: data.existing_template.type })
|
||||
}
|
||||
const message = data?.message || error.message || 'Failed to create template'
|
||||
setError(message)
|
||||
show({
|
||||
title: 'Template creation failed',
|
||||
description: data?.existing_template
|
||||
? `${message} — Existing: ${data.existing_template.title} (${data.existing_template.type}).`
|
||||
: message,
|
||||
variant: 'error',
|
||||
})
|
||||
} else {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create template'
|
||||
setError(message)
|
||||
show({ title: 'Template creation failed', description: message, variant: 'error' })
|
||||
}
|
||||
console.error('Error creating template:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Template from Edit</DialogTitle>
|
||||
<p className="text-sm text-gray-600">
|
||||
We'll help you create a comprehensive template. based on: <strong>{template.title}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
The new template will be created with 'pending' status for review.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Template Type *</Label>
|
||||
<Input
|
||||
id="type"
|
||||
placeholder="e.g., multi_restaurant_food_delivery"
|
||||
value={formData.type}
|
||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500">Unique identifier for the template</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="e.g., Multi-Restaurant Food Delivery App"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Describe your template and its key features..."
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category *</Label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleInputChange('category', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icon">Icon (optional)</Label>
|
||||
<Input
|
||||
id="icon"
|
||||
placeholder="e.g., restaurant, shopping-cart, users"
|
||||
value={formData.icon}
|
||||
onChange={(e) => handleInputChange('icon', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gradient">Gradient (optional)</Label>
|
||||
<Input
|
||||
id="gradient"
|
||||
placeholder="e.g., from-orange-400 to-red-500"
|
||||
value={formData.gradient}
|
||||
onChange={(e) => handleInputChange('gradient', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="border">Border (optional)</Label>
|
||||
<Input
|
||||
id="border"
|
||||
placeholder="e.g., border-orange-500"
|
||||
value={formData.border}
|
||||
onChange={(e) => handleInputChange('border', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">Text Color (optional)</Label>
|
||||
<Input
|
||||
id="text"
|
||||
placeholder="e.g., text-orange-500"
|
||||
value={formData.text}
|
||||
onChange={(e) => handleInputChange('text', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subtext">Subtext (optional)</Label>
|
||||
<Input
|
||||
id="subtext"
|
||||
placeholder="e.g., Perfect for food delivery startups"
|
||||
value={formData.subtext}
|
||||
onChange={(e) => handleInputChange('subtext', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{error}
|
||||
{conflictInfo && (
|
||||
<span className="block mt-1 text-xs text-gray-600">
|
||||
Existing: {conflictInfo.title} ({conflictInfo.type}). Try a different title.
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !formData.title.trim() || !formData.type.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Create New Template
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
490
src/components/admin/template-features-manager.tsx
Normal file
490
src/components/admin/template-features-manager.tsx
Normal file
@ -0,0 +1,490 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
Settings,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
RefreshCw
|
||||
} from 'lucide-react'
|
||||
import { adminApi, getComplexityColor } from '@/lib/api/admin'
|
||||
import { AdminFeature, AdminTemplate } from '@/types/admin.types'
|
||||
|
||||
interface TemplateFeature {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
complexity: 'low' | 'medium' | 'high'
|
||||
feature_type?: string
|
||||
business_rules?: Record<string, unknown>
|
||||
technical_requirements?: Record<string, unknown>
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
interface TemplateFeaturesManagerProps {
|
||||
template: AdminTemplate
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function TemplateFeaturesManager({ template, open, onOpenChange }: TemplateFeaturesManagerProps) {
|
||||
const [features, setFeatures] = useState<TemplateFeature[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showAddFeature, setShowAddFeature] = useState(false)
|
||||
const [editingFeature, setEditingFeature] = useState<TemplateFeature | null>(null)
|
||||
|
||||
// New feature form state
|
||||
const [newFeature, setNewFeature] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
complexity: 'low' as 'low' | 'medium' | 'high',
|
||||
feature_type: '',
|
||||
business_rules: '',
|
||||
technical_requirements: ''
|
||||
})
|
||||
|
||||
// Load template features
|
||||
const loadFeatures = async () => {
|
||||
if (!template.id) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const featuresData = await adminApi.getTemplateFeatures(template.id)
|
||||
setFeatures(featuresData as TemplateFeature[])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load features')
|
||||
console.error('Error loading template features:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open && template.id) {
|
||||
loadFeatures()
|
||||
}
|
||||
}, [open, template.id])
|
||||
|
||||
// Handle add feature
|
||||
const handleAddFeature = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!newFeature.name.trim()) {
|
||||
alert('Feature name is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const featureData = {
|
||||
name: newFeature.name,
|
||||
description: newFeature.description || undefined,
|
||||
complexity: newFeature.complexity,
|
||||
feature_type: newFeature.feature_type || undefined,
|
||||
business_rules: newFeature.business_rules ? JSON.parse(newFeature.business_rules) : undefined,
|
||||
technical_requirements: newFeature.technical_requirements ? JSON.parse(newFeature.technical_requirements) : undefined
|
||||
}
|
||||
|
||||
const addedFeature = await adminApi.addFeatureToTemplate(template.id, featureData)
|
||||
setFeatures(prev => [...prev, addedFeature as TemplateFeature])
|
||||
|
||||
// Reset form
|
||||
setNewFeature({
|
||||
name: '',
|
||||
description: '',
|
||||
complexity: 'low',
|
||||
feature_type: '',
|
||||
business_rules: '',
|
||||
technical_requirements: ''
|
||||
})
|
||||
setShowAddFeature(false)
|
||||
} catch (err) {
|
||||
console.error('Error adding feature:', err)
|
||||
alert('Failed to add feature: ' + (err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle update feature
|
||||
const handleUpdateFeature = async (featureId: string, updateData: Partial<TemplateFeature>) => {
|
||||
try {
|
||||
const updatedFeature = await adminApi.updateTemplateFeature(template.id, featureId, updateData)
|
||||
setFeatures(prev => prev.map(f => f.id === featureId ? updatedFeature as TemplateFeature : f))
|
||||
setEditingFeature(null)
|
||||
} catch (err) {
|
||||
console.error('Error updating feature:', err)
|
||||
alert('Failed to update feature: ' + (err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle delete feature
|
||||
const handleDeleteFeature = async (featureId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this feature?')) return
|
||||
|
||||
try {
|
||||
await adminApi.removeFeatureFromTemplate(template.id, featureId)
|
||||
setFeatures(prev => prev.filter(f => f.id !== featureId))
|
||||
} catch (err) {
|
||||
console.error('Error deleting feature:', err)
|
||||
alert('Failed to delete feature: ' + (err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle bulk add common features
|
||||
const handleBulkAddCommonFeatures = async () => {
|
||||
const commonFeatures = [
|
||||
{ name: 'User Authentication', description: 'Login and registration system', complexity: 'medium' as const },
|
||||
{ name: 'Responsive Design', description: 'Mobile-friendly layout', complexity: 'low' as const },
|
||||
{ name: 'SEO Optimization', description: 'Search engine optimization features', complexity: 'low' as const },
|
||||
{ name: 'Analytics Integration', description: 'Google Analytics or similar tracking', complexity: 'low' as const },
|
||||
{ name: 'Contact Form', description: 'Contact form with validation', complexity: 'low' as const },
|
||||
{ name: 'Content Management', description: 'CMS functionality for content updates', complexity: 'high' as const }
|
||||
]
|
||||
|
||||
try {
|
||||
const addedFeatures = await adminApi.bulkAddFeaturesToTemplate(template.id, commonFeatures)
|
||||
setFeatures(prev => [...prev, ...addedFeatures as TemplateFeature[]])
|
||||
} catch (err) {
|
||||
console.error('Error bulk adding features:', err)
|
||||
alert('Failed to add common features: ' + (err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
}
|
||||
|
||||
const FeatureCard = ({ feature }: { feature: TemplateFeature }) => (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h4 className="font-medium text-white">{feature.name}</h4>
|
||||
<Badge className={getComplexityColor(feature.complexity)}>
|
||||
{feature.complexity}
|
||||
</Badge>
|
||||
{feature.feature_type && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{feature.feature_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{feature.description && (
|
||||
<p className="text-gray-400 text-sm mb-2">{feature.description}</p>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{feature.created_at && `Added: ${new Date(feature.created_at).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingFeature(feature)}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteFeature(feature.id)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto bg-black border-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center space-x-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
<span>Manage Features - {template.title}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Header Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Zap className="h-5 w-5 text-orange-400" />
|
||||
<span className="text-white font-medium">Template Features ({features.length})</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={handleBulkAddCommonFeatures}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-white border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Common Features
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowAddFeature(true)}
|
||||
className="bg-orange-500 text-black hover:bg-orange-600"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Feature
|
||||
</Button>
|
||||
<Button
|
||||
onClick={loadFeatures}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-white border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-orange-400" />
|
||||
<span className="ml-2 text-white">Loading features...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<Card className="bg-red-900/20 border-red-800">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center space-x-2 text-red-400">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>Error: {error}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Features List */}
|
||||
{!loading && !error && (
|
||||
<div className="space-y-4">
|
||||
{features.length === 0 ? (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-gray-400">
|
||||
<Zap className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<p>No features added yet</p>
|
||||
<p className="text-sm">Add features to make this template more comprehensive.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{features.map((feature) => (
|
||||
<FeatureCard key={feature.id} feature={feature} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Feature Form */}
|
||||
{showAddFeature && (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center justify-between">
|
||||
<span>Add New Feature</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAddFeature(false)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleAddFeature} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feature-name" className="text-white">Feature Name *</Label>
|
||||
<Input
|
||||
id="feature-name"
|
||||
value={newFeature.name}
|
||||
onChange={(e) => setNewFeature(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="e.g., User Authentication"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feature-type" className="text-white">Feature Type</Label>
|
||||
<Input
|
||||
id="feature-type"
|
||||
value={newFeature.feature_type}
|
||||
onChange={(e) => setNewFeature(prev => ({ ...prev, feature_type: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="e.g., Authentication, UI, Backend"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feature-description" className="text-white">Description</Label>
|
||||
<Textarea
|
||||
id="feature-description"
|
||||
value={newFeature.description}
|
||||
onChange={(e) => setNewFeature(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="Describe what this feature does..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feature-complexity" className="text-white">Complexity</Label>
|
||||
<Select value={newFeature.complexity} onValueChange={(value) => setNewFeature(prev => ({ ...prev, complexity: value as 'low' | 'medium' | 'high' }))}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select complexity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowAddFeature(false)}
|
||||
className="text-white border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Edit Feature Dialog */}
|
||||
{editingFeature && (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center justify-between">
|
||||
<span>Edit Feature</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingFeature(null)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const formData = new FormData(e.currentTarget)
|
||||
handleUpdateFeature(editingFeature.id, {
|
||||
name: formData.get('name') as string,
|
||||
description: formData.get('description') as string,
|
||||
complexity: formData.get('complexity') as 'low' | 'medium' | 'high',
|
||||
feature_type: formData.get('feature_type') as string
|
||||
})
|
||||
}} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name" className="text-white">Feature Name</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
defaultValue={editingFeature.name}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-type" className="text-white">Feature Type</Label>
|
||||
<Input
|
||||
id="edit-type"
|
||||
name="feature_type"
|
||||
defaultValue={editingFeature.feature_type || ''}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description" className="text-white">Description</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
name="description"
|
||||
defaultValue={editingFeature.description || ''}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-complexity" className="text-white">Complexity</Label>
|
||||
<Select name="complexity" defaultValue={editingFeature.complexity}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditingFeature(null)}
|
||||
className="text-white border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Update Feature
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -4,6 +4,13 @@ import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { analyzeFeatureWithAI } from '@/services/aiAnalysis'
|
||||
|
||||
type Complexity = 'low' | 'medium' | 'high'
|
||||
@ -26,11 +33,12 @@ export function AICustomFeatureCreator({
|
||||
onClose,
|
||||
}: {
|
||||
projectType?: string
|
||||
onAdd: (feature: { name: string; description: string; complexity: Complexity }) => void
|
||||
onAdd: (feature: { name: string; description: string; complexity: Complexity; logic_rules?: string[] }) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [featureName, setFeatureName] = useState('')
|
||||
const [featureDescription, setFeatureDescription] = useState('')
|
||||
const [selectedComplexity, setSelectedComplexity] = useState<Complexity | undefined>(undefined)
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [aiAnalysis, setAiAnalysis] = useState<AIAnalysisResult | null>(null)
|
||||
const [analysisError, setAnalysisError] = useState<string | null>(null)
|
||||
@ -55,10 +63,10 @@ export function AICustomFeatureCreator({
|
||||
|
||||
setAiAnalysis({
|
||||
suggested_name: featureName,
|
||||
complexity: overall.complexity,
|
||||
complexity: overall.complexity, // Using the complexity from the API response
|
||||
implementation_details: [],
|
||||
technical_requirements: [],
|
||||
estimated_effort: 'Medium',
|
||||
estimated_effort: overall.complexity === 'high' ? 'High' : overall.complexity === 'low' ? 'Low' : 'Medium',
|
||||
dependencies: [],
|
||||
api_endpoints: [],
|
||||
database_tables: [],
|
||||
@ -83,7 +91,8 @@ export function AICustomFeatureCreator({
|
||||
onAdd({
|
||||
name: aiAnalysis.suggested_name || featureName.trim() || 'Custom Feature',
|
||||
description: featureDescription.trim(),
|
||||
complexity: aiAnalysis.complexity || 'medium',
|
||||
complexity: selectedComplexity || aiAnalysis.complexity || 'medium',
|
||||
logic_rules: logicRules,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
@ -109,6 +118,24 @@ export function AICustomFeatureCreator({
|
||||
<p className="text-xs text-white/50 mt-1">Be as detailed as possible. The AI will analyze and break down your requirements.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">Complexity Level</label>
|
||||
<Select
|
||||
value={selectedComplexity || ''}
|
||||
onValueChange={(value: Complexity) => setSelectedComplexity(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/10 border-white/20 text-white">
|
||||
<SelectValue placeholder="Select complexity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-white/10">
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-white/50 mt-1">Choose the complexity level for this feature</p>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Requirements List */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-white font-medium">Detailed Requirements (Add one by one)</div>
|
||||
@ -218,21 +245,21 @@ export function AICustomFeatureCreator({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex gap-3 flex-wrap items-center pt-4 border-t border-white/10">
|
||||
{aiAnalysis && (
|
||||
<div className="flex-1 text-white/80 text-sm">
|
||||
Overall Complexity: <span className="capitalize">{aiAnalysis.complexity}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose} className="border-white/20 text-white hover:bg-white/10">Cancel</Button>
|
||||
<Button type="submit" disabled={(!featureDescription.trim() && requirements.every(r => !r.text.trim())) || isAnalyzing || !selectedComplexity} className="bg-orange-500 hover:bg-orange-400 text-black">
|
||||
{aiAnalysis ? 'Add Feature with Tagged Rules' : 'Analyze & Add Feature'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-6 border-t border-white/10">
|
||||
<div className="flex gap-3 flex-wrap items-center">
|
||||
{aiAnalysis && (
|
||||
<div className="flex-1 text-white/80 text-sm">
|
||||
Overall Complexity: <span className="capitalize">{aiAnalysis.complexity}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose} className="border-white/20 text-white hover:bg-white/10">Cancel</Button>
|
||||
<Button type="submit" disabled={(!featureDescription.trim() && requirements.every(r => !r.text.trim())) || isAnalyzing} className="bg-orange-500 hover:bg-orange-400 text-black">
|
||||
{aiAnalysis ? 'Add Feature with Tagged Rules' : 'Analyze & Add Feature'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import axios from "axios";
|
||||
import { safeLocalStorage, safeRedirect } from "@/lib/utils";
|
||||
|
||||
const API_BASE_URL = "http://localhost:8011";
|
||||
import { safeLocalStorage } from "@/lib/utils";
|
||||
import { BACKEND_URL } from "@/config/backend";
|
||||
|
||||
let accessToken = safeLocalStorage.getItem('accessToken');
|
||||
let refreshToken = safeLocalStorage.getItem('refreshToken');
|
||||
@ -20,8 +19,43 @@ export const clearTokens = () => {
|
||||
safeLocalStorage.removeItem('refreshToken');
|
||||
};
|
||||
|
||||
export const getAccessToken = () => accessToken;
|
||||
export const getRefreshToken = () => refreshToken;
|
||||
export const getAccessToken = () => {
|
||||
// Always get the latest token from localStorage
|
||||
const token = safeLocalStorage.getItem('accessToken');
|
||||
console.log('🔐 [getAccessToken] Token check:', {
|
||||
hasToken: !!token,
|
||||
tokenLength: token?.length || 0,
|
||||
tokenStart: token?.substring(0, 20) + '...' || 'No token',
|
||||
timestamp: new Date().toISOString(),
|
||||
localStorageToken: token,
|
||||
moduleToken: accessToken
|
||||
});
|
||||
if (token) {
|
||||
accessToken = token; // Update the module variable
|
||||
console.log('🔐 [getAccessToken] Updated module token from localStorage');
|
||||
return token; // Return the fresh token from localStorage
|
||||
} else {
|
||||
console.log('🔐 [getAccessToken] No token found in localStorage');
|
||||
return null; // Return null if no token found
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to clear all authentication data
|
||||
// export const clearAllAuthData = () => {
|
||||
// console.log('🧹 Clearing all authentication data...');
|
||||
// clearTokens();
|
||||
// safeLocalStorage.removeItem('codenuk_user');
|
||||
// console.log('✅ All authentication data cleared');
|
||||
// };
|
||||
|
||||
export const getRefreshToken = () => {
|
||||
// Always get the latest token from localStorage
|
||||
const token = safeLocalStorage.getItem('refreshToken');
|
||||
if (token) {
|
||||
refreshToken = token; // Update the module variable
|
||||
}
|
||||
return refreshToken;
|
||||
};
|
||||
|
||||
// Logout function that calls the backend API
|
||||
export const logout = async () => {
|
||||
@ -42,7 +76,7 @@ export const logout = async () => {
|
||||
};
|
||||
|
||||
export const authApiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
baseURL: BACKEND_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import { authApiClient } from "./authApiClients";
|
||||
import { safeLocalStorage } from "@/lib/utils";
|
||||
import { BACKEND_URL } from "@/config/backend";
|
||||
|
||||
interface ApiResponse {
|
||||
data?: {
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiError extends Error {
|
||||
response?: any;
|
||||
response?: ApiResponse;
|
||||
}
|
||||
|
||||
export const registerUser = async (
|
||||
@ -17,19 +25,37 @@ export const registerUser = async (
|
||||
) => {
|
||||
console.log("Registering user with data:", data);
|
||||
try {
|
||||
const response = await authApiClient.post(
|
||||
"/api/auth/register",
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("Error registering user:", error.response?.data || error.message);
|
||||
// Using centralized backend URL
|
||||
const response = await fetch(`${BACKEND_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
// Create a proper error object that preserves the original response
|
||||
const responseData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Extract error message from server response
|
||||
const errorMessage = responseData?.message || responseData?.error || responseData?.detail || `Registration failed (${response.status})`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
} catch (error: unknown) {
|
||||
console.error("Error registering user:", error);
|
||||
|
||||
// If it's already our enhanced error with proper message, re-throw it
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create a proper error object for unexpected errors
|
||||
const enhancedError: ApiError = new Error(
|
||||
error.response?.data?.message || error.response?.data?.error || "Failed to register user. Please try again."
|
||||
"Failed to register user. Please try again."
|
||||
);
|
||||
enhancedError.response = error.response;
|
||||
throw enhancedError;
|
||||
}
|
||||
};
|
||||
@ -37,19 +63,37 @@ export const registerUser = async (
|
||||
export const loginUser = async (email: string, password: string) => {
|
||||
console.log("Logging in user with email:", email);
|
||||
try {
|
||||
const response = await authApiClient.post(
|
||||
"/api/auth/login",
|
||||
{ email, password }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("Error logging in user:", error.response?.data || error.message);
|
||||
// Using centralized backend URL
|
||||
const response = await fetch(`${BACKEND_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
// Create a proper error object that preserves the original response
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Extract error message from server response
|
||||
const errorMessage = data?.message || data?.error || data?.detail || `Login failed (${response.status})`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error: unknown) {
|
||||
console.error("Error logging in user:", error);
|
||||
|
||||
// If it's already our enhanced error with proper message, re-throw it
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create a proper error object for unexpected errors
|
||||
const enhancedError: ApiError = new Error(
|
||||
error.response?.data?.message || error.response?.data?.error || "Failed to log in. Please check your credentials."
|
||||
"Failed to log in. Please check your credentials."
|
||||
);
|
||||
enhancedError.response = error.response;
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
@ -59,17 +103,28 @@ export const logoutUser = async () => {
|
||||
try {
|
||||
const refreshToken = safeLocalStorage.getItem('refreshToken');
|
||||
if (refreshToken) {
|
||||
await authApiClient.post("/api/auth/logout", { refreshToken });
|
||||
// Using centralized backend URL
|
||||
const response = await fetch(`${BACKEND_URL}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ refreshToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
return { success: true, message: "Logged out successfully" };
|
||||
} catch (error: any) {
|
||||
console.error("Error logging out user:", error.response?.data || error.message);
|
||||
} catch (error: unknown) {
|
||||
console.error("Error logging out user:", error);
|
||||
|
||||
// Create a proper error object that preserves the original response
|
||||
// Create a proper error object
|
||||
const enhancedError: ApiError = new Error(
|
||||
error.response?.data?.message || error.response?.data?.error || "Failed to log out. Please try again."
|
||||
error instanceof Error ? error.message : "Failed to log out. Please try again."
|
||||
);
|
||||
enhancedError.response = error.response;
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,11 @@ import { useAuth } from "@/contexts/auth-context"
|
||||
import { loginUser } from "@/components/apis/authenticationHandler"
|
||||
import { setTokens } from "@/components/apis/authApiClients"
|
||||
|
||||
export function SignInForm() {
|
||||
interface SignInFormProps {
|
||||
onToggleMode?: () => void
|
||||
}
|
||||
|
||||
export function SignInForm({ }: SignInFormProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
@ -53,16 +57,17 @@ export function SignInForm() {
|
||||
} else {
|
||||
setError("Invalid response from server. Please try again.")
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Login error:', err)
|
||||
|
||||
// Handle different types of errors
|
||||
if (err.response?.data?.message) {
|
||||
setError(err.response.data.message)
|
||||
} else if (err.response?.data?.error) {
|
||||
setError(err.response.data.error)
|
||||
} else if (err.message) {
|
||||
setError(err.message)
|
||||
const error = err as { response?: { data?: { message?: string; error?: string } }; message?: string };
|
||||
if (error.response?.data?.message) {
|
||||
setError(error.response.data.message)
|
||||
} else if (error.response?.data?.error) {
|
||||
setError(error.response.data.error)
|
||||
} else if (error.message) {
|
||||
setError(error.message)
|
||||
} else {
|
||||
setError("An error occurred during login. Please try again.")
|
||||
}
|
||||
|
||||
@ -14,27 +14,29 @@ export function SignInPage() {
|
||||
|
||||
useEffect(() => {
|
||||
// Check for verification messages in URL
|
||||
const verified = searchParams.get('verified')
|
||||
const message = searchParams.get('message')
|
||||
const error = searchParams.get('error')
|
||||
if (typeof window !== 'undefined') {
|
||||
const verified = searchParams.get('verified')
|
||||
const message = searchParams.get('message')
|
||||
const error = searchParams.get('error')
|
||||
|
||||
if (verified === 'true') {
|
||||
setVerificationMessage('Email verified successfully! You can now sign in to your account.')
|
||||
setVerificationType('success')
|
||||
} else if (message) {
|
||||
setVerificationMessage(decodeURIComponent(message))
|
||||
setVerificationType('success')
|
||||
} else if (error) {
|
||||
setVerificationMessage(decodeURIComponent(error))
|
||||
setVerificationType('error')
|
||||
}
|
||||
if (verified === 'true') {
|
||||
setVerificationMessage('Email verified successfully! You can now sign in to your account.')
|
||||
setVerificationType('success')
|
||||
} else if (message) {
|
||||
setVerificationMessage(decodeURIComponent(message))
|
||||
setVerificationType('success')
|
||||
} else if (error) {
|
||||
setVerificationMessage(decodeURIComponent(error))
|
||||
setVerificationType('error')
|
||||
}
|
||||
|
||||
// Clear the message after 5 seconds
|
||||
if (verified || message || error) {
|
||||
const timer = setTimeout(() => {
|
||||
setVerificationMessage(null)
|
||||
}, 5000)
|
||||
return () => clearTimeout(timer)
|
||||
// Clear the message after 5 seconds
|
||||
if (verified || message || error) {
|
||||
const timer = setTimeout(() => {
|
||||
setVerificationMessage(null)
|
||||
}, 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
@ -106,7 +108,7 @@ export function SignInPage() {
|
||||
<SignInForm />
|
||||
|
||||
<div className="text-center pt-4">
|
||||
<div className="text-white/60 text-xs mb-1">Don't have an account?</div>
|
||||
<div className="text-white/60 text-xs mb-1">Don't have an account?</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
|
||||
@ -7,13 +7,13 @@ import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
// Removed unused Card components
|
||||
import { Eye, EyeOff, Loader2, User, Mail, Lock, Shield } from "lucide-react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { registerUser } from "../apis/authenticationHandler"
|
||||
|
||||
interface SignUpFormProps {
|
||||
onSignUpSuccess?: () => void
|
||||
onToggleMode?: () => void
|
||||
}
|
||||
|
||||
export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
|
||||
@ -31,6 +31,7 @@ export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
|
||||
role: "user" // default role, adjust as needed
|
||||
})
|
||||
|
||||
// const { signup } = useAuth() // Unused variable
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@ -70,18 +71,18 @@ export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
|
||||
onSignUpSuccess()
|
||||
} else {
|
||||
// Default behavior - redirect to signin with message
|
||||
router.push("/signin?message=Account created successfully! Please check your email to verify your account.")
|
||||
if (typeof window !== 'undefined') {
|
||||
router.push("/signin?message=Account created successfully! Please check your email to verify your account.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError(response.message || "Failed to create account. Please try again.")
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Signup error:', err)
|
||||
|
||||
// Handle different types of errors
|
||||
if (err.response?.data?.message) {
|
||||
setError(err.response.data.message)
|
||||
} else if (err.message) {
|
||||
// The authentication handler now properly extracts error messages from server responses
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError("An error occurred during registration. Please try again.")
|
||||
|
||||
@ -14,8 +14,10 @@ export function SignUpPage() {
|
||||
setIsSuccess(true)
|
||||
// Redirect to signin after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push("/signin?message=Please check your email to verify your account")
|
||||
}, 3000)
|
||||
if (typeof window !== 'undefined') {
|
||||
router.push("/signin?message=Please check your email to verify your account")
|
||||
}
|
||||
}, 3001)
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
@ -68,7 +70,7 @@ export function SignUpPage() {
|
||||
<CheckCircle className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">Account Created Successfully!</h1>
|
||||
<p className="text-white/60 mb-6">We've sent a verification email to your inbox. Please check your email and click the verification link to activate your account.</p>
|
||||
<p className="text-white/60 mb-6">We've sent a verification email to your inbox. Please check your email and click the verification link to activate your account.</p>
|
||||
<div className="bg-orange-500/10 border border-orange-500/30 rounded-lg p-4 mb-6">
|
||||
<p className="text-orange-300 text-sm">
|
||||
<strong>Next step:</strong> Check your email and click the verification link, then sign in to your account.
|
||||
|
||||
123
src/components/edit-feature-form.tsx
Normal file
123
src/components/edit-feature-form.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { TemplateFeature } from "@/lib/template-service"
|
||||
|
||||
interface EditFeatureFormProps {
|
||||
feature: TemplateFeature
|
||||
onSubmit: (feature: Partial<TemplateFeature>) => Promise<void>
|
||||
onCancel: () => void
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export function EditFeatureForm({ feature, onSubmit, onCancel, isOpen }: EditFeatureFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: feature.name || "",
|
||||
description: feature.description || "",
|
||||
complexity: feature.complexity || "medium",
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onSubmit(formData)
|
||||
onCancel() // Close the dialog on success
|
||||
} catch (error) {
|
||||
console.error('Error updating feature:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onCancel}>
|
||||
<DialogContent className="sm:max-w-[425px] bg-gray-900 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Feature</DialogTitle>
|
||||
<DialogDescription>
|
||||
Make changes to your feature here. Click save when you're done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Feature Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Enter feature name"
|
||||
required
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Enter description"
|
||||
rows={3}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Complexity</Label>
|
||||
<Select
|
||||
value={formData.complexity}
|
||||
onValueChange={(value: "low" | "medium" | "high") =>
|
||||
setFormData({ ...formData, complexity: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select complexity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-white/10">
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-orange-500 hover:bg-orange-400 text-black"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
156
src/components/layout/admin-layout.tsx
Normal file
156
src/components/layout/admin-layout.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Bell, Settings, LogOut, User, Shield, ArrowLeft } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { AdminNotificationsPanel } from "@/components/admin/admin-notifications-panel"
|
||||
import { useAdminNotifications } from "@/contexts/AdminNotificationContext"
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AdminLayout({ children }: AdminLayoutProps) {
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
// const pathname = usePathname()
|
||||
const { user, logout, isAdmin } = useAuth()
|
||||
const { unreadCount } = useAdminNotifications()
|
||||
|
||||
// Handle logout with loading state
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setIsLoggingOut(true)
|
||||
await logout()
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
setIsLoggingOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect non-admin users
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-red-600 mb-4">Access Denied</h1>
|
||||
<p className="text-gray-600 mb-4">You don't have permission to access the admin panel.</p>
|
||||
<Link href="/">
|
||||
<Button>Go to Home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
{/* Admin Header */}
|
||||
<header className="bg-black/90 text-white border-b border-white/10 backdrop-blur">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 justify-between items-center">
|
||||
{/* Logo and Admin Title */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<ArrowLeft className="h-5 w-5 text-white/70 hover:text-white" />
|
||||
</Link>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||
<span className="text-black font-bold text-sm">C</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold">Codenuk</span>
|
||||
<Badge className="bg-orange-500 text-black">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Notifications */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative text-white/80 hover:text-white hover:bg-white/5"
|
||||
onClick={() => setShowNotifications(true)}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs bg-orange-500 text-black">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* User Menu */}
|
||||
{user && user.email && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="/avatars/01.png" alt={user.username || user.email || "User"} />
|
||||
<AvatarFallback>
|
||||
{(user.username && user.username.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.username || user.email || "User"}</p>
|
||||
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
|
||||
<Badge className="w-fit bg-orange-500 text-black text-xs">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="hover:bg-white/5 cursor-pointer" disabled={isLoggingOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>{isLoggingOut ? "Logging out..." : "Log out"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Admin Content */}
|
||||
<main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Notifications Panel */}
|
||||
<AdminNotificationsPanel
|
||||
open={showNotifications}
|
||||
onOpenChange={setShowNotifications}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
348
src/components/layout/admin-sidebar-layout.tsx
Normal file
348
src/components/layout/admin-sidebar-layout.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
"use client"
|
||||
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Bell,
|
||||
Settings,
|
||||
LogOut,
|
||||
User,
|
||||
Shield,
|
||||
ArrowLeft,
|
||||
LayoutDashboard,
|
||||
Files,
|
||||
Zap,
|
||||
Users,
|
||||
BarChart3,
|
||||
FileText,
|
||||
Cog,
|
||||
HelpCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { AdminNotificationsPanel } from "@/components/admin/admin-notifications-panel"
|
||||
import { useAdminNotifications } from "@/contexts/AdminNotificationContext"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface AdminSidebarLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
interface SidebarItem {
|
||||
id: string
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
href?: string
|
||||
badge?: number
|
||||
subItems?: SidebarItem[]
|
||||
}
|
||||
|
||||
export function AdminSidebarLayout({ children }: AdminSidebarLayoutProps) {
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const { user, logout, isAdmin } = useAuth()
|
||||
const { unreadCount } = useAdminNotifications()
|
||||
|
||||
// Handle logout with loading state
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setIsLoggingOut(true)
|
||||
await logout()
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
setIsLoggingOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar navigation items
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{
|
||||
id: "dashboard",
|
||||
label: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
href: "/admin"
|
||||
},
|
||||
{
|
||||
id: "features",
|
||||
label: "Custom Features",
|
||||
icon: Zap,
|
||||
href: "/admin?tab=features",
|
||||
badge: 0 // This will be updated dynamically
|
||||
},
|
||||
{
|
||||
id: "templates",
|
||||
label: "Templates",
|
||||
icon: Files,
|
||||
href: "/admin/templates"
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
label: "User Management",
|
||||
icon: Users,
|
||||
href: "/admin/users"
|
||||
},
|
||||
{
|
||||
id: "analytics",
|
||||
label: "Analytics",
|
||||
icon: BarChart3,
|
||||
href: "/admin/analytics"
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
icon: Cog,
|
||||
href: "/admin/settings"
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
label: "Help & Support",
|
||||
icon: HelpCircle,
|
||||
href: "/admin/help"
|
||||
}
|
||||
]
|
||||
|
||||
// Redirect non-admin users
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-red-600 mb-4">Access Denied</h1>
|
||||
<p className="text-gray-600 mb-4">You don't have permission to access the admin panel.</p>
|
||||
<Link href="/">
|
||||
<Button>Go to Home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarItem = ({ item, level = 0 }: { item: SidebarItem; level?: number }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const hasSubItems = item.subItems && item.subItems.length > 0
|
||||
const isActive = pathname === item.href || (item.href && pathname.startsWith(item.href))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
href={item.href || "#"}
|
||||
onClick={hasSubItems ? (e) => {
|
||||
e.preventDefault()
|
||||
setIsExpanded(!isExpanded)
|
||||
} : undefined}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-3 py-2 text-sm rounded-lg transition-colors",
|
||||
level > 0 && "ml-4 pl-6",
|
||||
isActive
|
||||
? "bg-orange-500 text-black font-medium"
|
||||
: "text-white/80 hover:bg-white/10 hover:text-white",
|
||||
sidebarCollapsed && level === 0 && "justify-center px-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<item.icon className={cn("h-5 w-5", sidebarCollapsed && level === 0 && "h-6 w-6")} />
|
||||
{(!sidebarCollapsed || level > 0) && (
|
||||
<span className="truncate">{item.label}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{item.badge !== undefined && item.badge > 0 && (
|
||||
<Badge className="bg-red-500 text-white text-xs h-5 w-5 rounded-full p-0 flex items-center justify-center">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
{hasSubItems && (
|
||||
<ChevronRight className={cn("h-4 w-4 transition-transform", isExpanded && "rotate-90")} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{hasSubItems && isExpanded && !sidebarCollapsed && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{item.subItems!.map((subItem) => (
|
||||
<SidebarItem key={subItem.id} item={subItem} level={level + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex">
|
||||
{/* Sidebar */}
|
||||
<div className={cn(
|
||||
"bg-gray-900 border-r border-white/10 transition-all duration-300 flex flex-col",
|
||||
sidebarCollapsed ? "w-16" : "w-64"
|
||||
)}>
|
||||
{/* Sidebar Header */}
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||
<span className="text-black font-bold text-sm">C</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold">Admin</span>
|
||||
<Badge className="bg-orange-500 text-black text-xs">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Panel
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="text-white/70 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
{sidebarCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{sidebarItems.map((item) => (
|
||||
<SidebarItem key={item.id} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Footer */}
|
||||
<div className="p-4 border-t border-white/10">
|
||||
{!sidebarCollapsed ? (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="/avatars/01.png" alt={user?.username || user?.email || "User"} />
|
||||
<AvatarFallback className="bg-orange-500 text-black">
|
||||
{(user?.username && user.username.charAt(0)) || (user?.email && user.email.charAt(0)) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{user?.username || user?.email || "User"}
|
||||
</p>
|
||||
<p className="text-xs text-white/60 truncate">Administrator</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="/avatars/01.png" alt={user?.username || user?.email || "User"} />
|
||||
<AvatarFallback className="bg-orange-500 text-black">
|
||||
{(user?.username && user.username.charAt(0)) || (user?.email && user.email.charAt(0)) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Top Header */}
|
||||
<header className="bg-black/90 text-white border-b border-white/10 backdrop-blur">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex h-8 justify-between items-center">
|
||||
{/* Back to Main Site */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/" className="flex items-center space-x-2 text-white/70 hover:text-white">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="text-sm">Back to Site</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Notifications */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative text-white/80 hover:text-white hover:bg-white/5"
|
||||
onClick={() => setShowNotifications(true)}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs bg-orange-500 text-black">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* User Menu */}
|
||||
{user && user.email && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="/avatars/01.png" alt={user.username || user.email || "User"} />
|
||||
<AvatarFallback>
|
||||
{(user.username && user.username.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.username || user.email || "User"}</p>
|
||||
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
|
||||
<Badge className="w-fit bg-orange-500 text-black text-xs">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="hover:bg-white/5 cursor-pointer" disabled={isLoggingOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>{isLoggingOut ? "Logging out..." : "Log out"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 px-6 py-8 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Notifications Panel */}
|
||||
<AdminNotificationsPanel
|
||||
open={showNotifications}
|
||||
onOpenChange={setShowNotifications}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { Header } from "@/components/navigation/header"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
@ -9,14 +8,15 @@ interface AppLayoutProps {
|
||||
}
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const { user } = useAuth()
|
||||
// const { user } = useAuth()
|
||||
const pathname = usePathname()
|
||||
|
||||
// Don't show header on auth pages
|
||||
// Don't show header on auth pages or admin pages
|
||||
const isAuthPage = pathname === "/signin" || pathname === "/signup"
|
||||
const isAdminPage = pathname?.startsWith("/admin")
|
||||
|
||||
// For auth pages, don't show header
|
||||
if (isAuthPage) {
|
||||
// For auth pages and admin pages, don't show header
|
||||
if (isAuthPage || isAdminPage) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -17,14 +17,6 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Bell, Settings, LogOut, User, Menu, X, Shield } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
|
||||
const navigation = [
|
||||
{ name: "Project Builder", href: "/project-builder" },
|
||||
{ name: "Templates", href: "/templates" },
|
||||
{ name: "Features", href: "/features" },
|
||||
{ name: "Business Context", href: "/business-context" },
|
||||
{ name: "Architecture", href: "/architecture" },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
@ -64,19 +56,20 @@ export function Header() {
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex space-x-2">
|
||||
{navigation.map((item) => (
|
||||
{/* Hide Project Builder and other user nav when admin is logged in */}
|
||||
{!isAdmin && (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
key="project-builder"
|
||||
href="/project-builder"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
pathname === item.href
|
||||
pathname === "/project-builder"
|
||||
? "bg-orange-500 text-black"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
Project Builder
|
||||
</Link>
|
||||
))}
|
||||
)}
|
||||
{/* Admin Navigation */}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
@ -118,9 +111,9 @@ export function Header() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatar || "/avatars/01.png"} alt={user.name || user.email || "User"} />
|
||||
<AvatarImage src="/avatars/01.png" alt={user.username || user.email || "User"} />
|
||||
<AvatarFallback>
|
||||
{(user.name && user.name.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
|
||||
{(user.username && user.username.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
@ -128,7 +121,7 @@ export function Header() {
|
||||
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.name || user.email || "User"}</p>
|
||||
<p className="text-sm font-medium leading-none">{user.username || user.email || "User"}</p>
|
||||
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
|
||||
{isAdmin && (
|
||||
<Badge className="w-fit bg-orange-500 text-black text-xs">
|
||||
@ -172,20 +165,20 @@ export function Header() {
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden py-4 border-t border-white/10">
|
||||
<nav className="flex flex-col space-y-2">
|
||||
{navigation.map((item) => (
|
||||
{!isAdmin && (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
key="project-builder-mobile"
|
||||
href="/project-builder"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
pathname === item.href
|
||||
pathname === "/project-builder"
|
||||
? "bg-orange-500 text-black"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{item.name}
|
||||
Project Builder
|
||||
</Link>
|
||||
))}
|
||||
)}
|
||||
{/* Admin Navigation for mobile */}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
|
||||
@ -6,7 +6,6 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"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",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
|
||||
31
src/components/ui/switch.tsx
Normal file
31
src/components/ui/switch.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
92
src/components/ui/toast.tsx
Normal file
92
src/components/ui/toast.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from "react"
|
||||
|
||||
type ToastVariant = "success" | "error" | "info" | "warning"
|
||||
|
||||
type Toast = {
|
||||
id: string
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: ToastVariant
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
type ToastContextValue = {
|
||||
toasts: Toast[]
|
||||
show: (toast: Omit<Toast, "id">) => void
|
||||
dismiss: (id: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const show = useCallback((toast: Omit<Toast, "id">) => {
|
||||
const id = Math.random().toString(36).slice(2)
|
||||
const duration = toast.durationMs ?? 4000
|
||||
const next: Toast = { id, ...toast }
|
||||
setToasts((prev) => [...prev, next])
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismiss(id), duration)
|
||||
}
|
||||
}, [dismiss])
|
||||
|
||||
const value = useMemo(() => ({ toasts, show, dismiss }), [toasts, show, dismiss])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) throw new Error("useToast must be used within <ToastProvider>")
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function Toaster() {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) return null
|
||||
const { toasts, dismiss } = ctx
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={[
|
||||
"min-w-[280px] max-w-sm rounded-md border px-4 py-3 shadow-lg",
|
||||
t.variant === "success" && "bg-green-50 border-green-300 text-green-900",
|
||||
t.variant === "error" && "bg-red-50 border-red-300 text-red-900",
|
||||
t.variant === "warning" && "bg-yellow-50 border-yellow-300 text-yellow-900",
|
||||
(!t.variant || t.variant === "info") && "bg-white border-gray-200 text-gray-900",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
{t.title && <div className="font-semibold">{t.title}</div>}
|
||||
{t.description && <div className="text-sm opacity-90 mt-0.5">{t.description}</div>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismiss(t.id)}
|
||||
className="text-sm opacity-60 hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
33
src/components/ui/tooltip.tsx
Normal file
33
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
type TooltipProps = {
|
||||
content: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
};
|
||||
|
||||
export function Tooltip({ content, children, side = "top" }: TooltipProps) {
|
||||
const sideClasses: Record<string, string> = {
|
||||
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
|
||||
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
|
||||
left: "right-full top-1/2 -translate-y-1/2 mr-2",
|
||||
right: "left-full top-1/2 -translate-y-1/2 ml-2",
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex group">
|
||||
{children}
|
||||
<span
|
||||
className={`pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-150 absolute z-50 ${sideClasses[side]} max-w-xs`}
|
||||
>
|
||||
<span className="block rounded-md bg-black text-white text-xs px-2 py-1 shadow-lg border border-white/10 whitespace-normal break-words">
|
||||
{content}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
25
src/config/backend.ts
Normal file
25
src/config/backend.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Centralized Backend Configuration
|
||||
* Single source of truth for all backend URLs
|
||||
*/
|
||||
|
||||
// Main backend URL - change this to update all API calls
|
||||
export const BACKEND_URL = 'http://localhost:8000';
|
||||
// export const BACKEND_URL = 'https://backend.codenuk.com';
|
||||
|
||||
|
||||
// Realtime notifications socket URL (Template Manager emits notifications)
|
||||
// Prefer env override if present; fallback to local Template Manager port
|
||||
export const SOCKET_URL = process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:8009';
|
||||
|
||||
|
||||
|
||||
// Export for backward compatibility
|
||||
export const API_BASE_URL = BACKEND_URL;
|
||||
|
||||
// Helper function to get full API endpoint URL
|
||||
export const getApiUrl = (endpoint: string): string => {
|
||||
// Remove leading slash if present to avoid double slashes
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
|
||||
return `${BACKEND_URL}/${cleanEndpoint}`;
|
||||
};
|
||||
141
src/contexts/AdminNotificationContext.tsx
Normal file
141
src/contexts/AdminNotificationContext.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { AdminNotification } from '@/types/admin.types';
|
||||
import { useNotificationSocket } from '@/hooks/useNotificationSocket';
|
||||
import { adminApi } from '@/lib/api/admin';
|
||||
|
||||
interface AdminNotificationContextType {
|
||||
notifications: AdminNotification[];
|
||||
unreadCount: number;
|
||||
isConnected: boolean;
|
||||
refreshNotifications: () => Promise<void>;
|
||||
markAsRead: (id: string) => Promise<void>;
|
||||
markAllAsRead: () => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
removeByReference: (referenceType: string, referenceId: string) => void;
|
||||
}
|
||||
|
||||
const AdminNotificationContext = createContext<AdminNotificationContextType | undefined>(undefined);
|
||||
|
||||
export function AdminNotificationProvider({ children }: { children: React.ReactNode }) {
|
||||
const [notifications, setNotifications] = useState<AdminNotification[]>([]);
|
||||
const [, setIsLoading] = useState(false);
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
notificationCounts,
|
||||
onNewNotification,
|
||||
onNotificationRead,
|
||||
onAllNotificationsRead
|
||||
} = useNotificationSocket();
|
||||
|
||||
// Load initial notifications
|
||||
const refreshNotifications = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await adminApi.getNotifications(false, 100, 0);
|
||||
setNotifications(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching notifications:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Mark single notification as read
|
||||
const markAsRead = async (id: string) => {
|
||||
try {
|
||||
await adminApi.markNotificationAsRead(id);
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === id ? { ...n, is_read: true, read_at: new Date().toISOString() } : n)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as read:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Mark all notifications as read
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
await adminApi.markAllNotificationsAsRead();
|
||||
setNotifications(prev =>
|
||||
prev.map(n => ({ ...n, is_read: true, read_at: new Date().toISOString() }))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error marking all notifications as read:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all notifications from UI (after marking as read server-side)
|
||||
const clearAll = async () => {
|
||||
await markAllAsRead();
|
||||
setNotifications([]);
|
||||
};
|
||||
|
||||
// Remove notifications tied to a specific backend reference (e.g., feature/template id)
|
||||
const removeByReference = (referenceType: string, referenceId: string) => {
|
||||
setNotifications(prev => prev.filter(n => !(n.reference_type === referenceType && n.reference_id === referenceId)));
|
||||
};
|
||||
|
||||
// Set up WebSocket event handlers
|
||||
useEffect(() => {
|
||||
onNewNotification((notification: AdminNotification) => {
|
||||
setNotifications(prev => [notification, ...prev]);
|
||||
});
|
||||
|
||||
onNotificationRead(({ id }: { id: string }) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === id ? { ...n, is_read: true, read_at: new Date().toISOString() } : n)
|
||||
);
|
||||
});
|
||||
|
||||
onAllNotificationsRead(() => {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => ({ ...n, is_read: true, read_at: new Date().toISOString() }))
|
||||
);
|
||||
});
|
||||
}, [onNewNotification, onNotificationRead, onAllNotificationsRead]);
|
||||
|
||||
// Load notifications on mount
|
||||
useEffect(() => {
|
||||
refreshNotifications();
|
||||
}, []);
|
||||
|
||||
// When socket connects (e.g., navigating to admin), refresh to include any
|
||||
// notifications created before the connection was established
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
refreshNotifications();
|
||||
}
|
||||
}, [isConnected]);
|
||||
|
||||
const value: AdminNotificationContextType = {
|
||||
notifications,
|
||||
unreadCount: notificationCounts.unread > 0
|
||||
? notificationCounts.unread
|
||||
: notifications.reduce((acc, n) => acc + (n.is_read ? 0 : 1), 0),
|
||||
isConnected,
|
||||
refreshNotifications,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
clearAll,
|
||||
removeByReference,
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</AdminNotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAdminNotifications() {
|
||||
const context = useContext(AdminNotificationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAdminNotifications must be used within an AdminNotificationProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
'use client'
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { safeLocalStorage } from '@/lib/utils'
|
||||
@ -14,6 +14,7 @@ interface User {
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
isAdmin: boolean
|
||||
isLoading: boolean
|
||||
setUserFromApi: (user: User) => void
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
@ -22,23 +23,78 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = safeLocalStorage.getItem('codenuk_user')
|
||||
if (stored) {
|
||||
const accessToken = safeLocalStorage.getItem('accessToken')
|
||||
const refreshToken = safeLocalStorage.getItem('refreshToken')
|
||||
|
||||
console.log('🔐 [AuthContext] Checking localStorage for user data...')
|
||||
console.log('🔐 [AuthContext] Stored user data:', stored)
|
||||
console.log('🔐 [AuthContext] Access token exists:', !!accessToken)
|
||||
console.log('🔐 [AuthContext] Refresh token exists:', !!refreshToken)
|
||||
console.log('🔐 [AuthContext] Token details:', {
|
||||
accessTokenLength: accessToken?.length || 0,
|
||||
refreshTokenLength: refreshToken?.length || 0,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
// Only restore user if we have both user data AND at least one token
|
||||
if (stored && (accessToken || refreshToken)) {
|
||||
try {
|
||||
const userData = JSON.parse(stored)
|
||||
console.log('🔐 [AuthContext] Restoring user session:', userData?.username)
|
||||
console.log('🔐 [AuthContext] User ID from localStorage:', userData?.id)
|
||||
setUser(userData)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored user data:', error)
|
||||
safeLocalStorage.removeItem('codenuk_user')
|
||||
}
|
||||
} else {
|
||||
console.log('🔐 [AuthContext] No valid user session found')
|
||||
// Clear any partial data
|
||||
if (stored && !accessToken && !refreshToken) {
|
||||
console.log('🔐 [AuthContext] Clearing orphaned user data without tokens')
|
||||
safeLocalStorage.removeItem('codenuk_user')
|
||||
}
|
||||
}
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
// Listen for storage changes to sync auth state across tabs
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'codenuk_user' || e.key === 'accessToken' || e.key === 'refreshToken') {
|
||||
console.log('🔐 [AuthContext] Storage changed, re-checking auth state')
|
||||
const stored = safeLocalStorage.getItem('codenuk_user')
|
||||
const accessToken = safeLocalStorage.getItem('accessToken')
|
||||
const refreshToken = safeLocalStorage.getItem('refreshToken')
|
||||
|
||||
if (stored && (accessToken || refreshToken)) {
|
||||
try {
|
||||
const userData = JSON.parse(stored)
|
||||
setUser(userData)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse user data after storage change:', error)
|
||||
setUser(null)
|
||||
}
|
||||
} else {
|
||||
setUser(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
return () => window.removeEventListener('storage', handleStorageChange)
|
||||
}, [])
|
||||
|
||||
const setUserFromApi = (u: User) => {
|
||||
console.log('🔐 [AuthContext] Setting user from API:', u)
|
||||
console.log('🔐 [AuthContext] User ID being set:', u?.id)
|
||||
setUser(u)
|
||||
safeLocalStorage.setItem('codenuk_user', JSON.stringify(u))
|
||||
console.log('🔐 [AuthContext] User data saved to localStorage')
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
@ -61,7 +117,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isAdmin: user?.role === 'admin', setUserFromApi, logout }}>
|
||||
<AuthContext.Provider value={{ user, isAdmin: user?.role === 'admin', isLoading, setUserFromApi, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
123
src/hooks/useNotificationSocket.ts
Normal file
123
src/hooks/useNotificationSocket.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { AdminNotification } from '@/types/admin.types';
|
||||
import { SOCKET_URL } from '@/config/backend';
|
||||
import { getAccessToken } from '@/components/apis/authApiClients';
|
||||
|
||||
interface NotificationCounts {
|
||||
total: number;
|
||||
unread: number;
|
||||
read: number;
|
||||
}
|
||||
|
||||
interface UseNotificationSocketReturn {
|
||||
socket: Socket | null;
|
||||
isConnected: boolean;
|
||||
notificationCounts: NotificationCounts;
|
||||
onNewNotification: (callback: (notification: AdminNotification) => void) => void;
|
||||
onNotificationRead: (callback: (data: { id: string }) => void) => void;
|
||||
onAllNotificationsRead: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
export function useNotificationSocket(): UseNotificationSocketReturn {
|
||||
const [socket, setSocket] = useState<Socket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [notificationCounts, setNotificationCounts] = useState<NotificationCounts>({
|
||||
total: 0,
|
||||
unread: 0,
|
||||
read: 0
|
||||
});
|
||||
|
||||
const newNotificationCallbackRef = useRef<((notification: AdminNotification) => void) | null>(null);
|
||||
const notificationReadCallbackRef = useRef<((data: { id: string }) => void) | null>(null);
|
||||
const allNotificationsReadCallbackRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize socket connection
|
||||
const templateManagerUrl = SOCKET_URL; // connect directly to template-manager socket server
|
||||
const token = getAccessToken();
|
||||
console.log('[useNotificationSocket] Initializing socket', {
|
||||
url: templateManagerUrl,
|
||||
hasToken: !!token,
|
||||
tokenPreview: token ? token.substring(0, 12) + '...' : null
|
||||
});
|
||||
const newSocket = io(templateManagerUrl, {
|
||||
transports: ['websocket', 'polling'],
|
||||
timeout: 20000,
|
||||
path: '/socket.io/',
|
||||
auth: token ? { token } : undefined,
|
||||
});
|
||||
|
||||
newSocket.on('connect', () => {
|
||||
console.log('🔌 Connected to notification socket');
|
||||
setIsConnected(true);
|
||||
});
|
||||
|
||||
newSocket.on('disconnect', () => {
|
||||
console.log('🔌 Disconnected from notification socket');
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
newSocket.on('notification-count', (counts: NotificationCounts) => {
|
||||
console.log('📊 Notification counts updated:', counts);
|
||||
setNotificationCounts(counts);
|
||||
});
|
||||
|
||||
newSocket.on('new-notification', (notification: AdminNotification) => {
|
||||
console.log('🔔 New notification received:', notification);
|
||||
if (newNotificationCallbackRef.current) {
|
||||
newNotificationCallbackRef.current(notification);
|
||||
}
|
||||
});
|
||||
|
||||
newSocket.on('notification-read', (data: { id: string }) => {
|
||||
console.log('✅ Notification marked as read:', data.id);
|
||||
if (notificationReadCallbackRef.current) {
|
||||
notificationReadCallbackRef.current(data);
|
||||
}
|
||||
});
|
||||
|
||||
newSocket.on('all-notifications-read', () => {
|
||||
console.log('✅ All notifications marked as read');
|
||||
if (allNotificationsReadCallbackRef.current) {
|
||||
allNotificationsReadCallbackRef.current();
|
||||
}
|
||||
});
|
||||
|
||||
newSocket.on('connect_error', (error: any) => {
|
||||
console.error('🔌 Socket connection error:', {
|
||||
message: error?.message,
|
||||
name: error?.name,
|
||||
data: error?.data
|
||||
});
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
|
||||
return () => {
|
||||
newSocket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onNewNotification = (callback: (notification: AdminNotification) => void) => {
|
||||
newNotificationCallbackRef.current = callback;
|
||||
};
|
||||
|
||||
const onNotificationRead = (callback: (data: { id: string }) => void) => {
|
||||
notificationReadCallbackRef.current = callback;
|
||||
};
|
||||
|
||||
const onAllNotificationsRead = (callback: () => void) => {
|
||||
allNotificationsReadCallbackRef.current = callback;
|
||||
};
|
||||
|
||||
return {
|
||||
socket,
|
||||
isConnected,
|
||||
notificationCounts,
|
||||
onNewNotification,
|
||||
onNotificationRead,
|
||||
onAllNotificationsRead,
|
||||
};
|
||||
}
|
||||
@ -1,161 +1,351 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { templateService, DatabaseTemplate, TemplatesByCategory, TemplateFeature } from '@/lib/template-service'
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { templateService, DatabaseTemplate, TemplateFeature, TemplatesByCategory } from '@/lib/template-service';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
|
||||
export function useTemplates() {
|
||||
const [templates, setTemplates] = useState<TemplatesByCategory>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await templateService.getTemplatesByCategory()
|
||||
setTemplates(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch templates')
|
||||
console.error('Error fetching templates:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
|
||||
// Log user ID for debugging
|
||||
useEffect(() => {
|
||||
fetchTemplates()
|
||||
}, [])
|
||||
console.log('🔍 [useTemplates] Current user ID:', user?.id || 'No user ID')
|
||||
console.log('👤 [useTemplates] User object:', user)
|
||||
console.log('🔐 [useTemplates] Auth state - user exists:', !!user)
|
||||
console.log('🔐 [useTemplates] Auth state - user.id exists:', !!user?.id)
|
||||
console.log('🔐 [useTemplates] User type:', typeof user)
|
||||
console.log('🔐 [useTemplates] User keys:', user ? Object.keys(user) : 'No user object')
|
||||
console.log('🔐 [useTemplates] Auth loading:', authLoading)
|
||||
console.log('🔐 [useTemplates] Timestamp:', new Date().toISOString())
|
||||
|
||||
const createTemplate = async (templateData: Partial<DatabaseTemplate>) => {
|
||||
try {
|
||||
const newTemplate = await templateService.createTemplate(templateData)
|
||||
// Refresh templates after creating a new one
|
||||
await fetchTemplates()
|
||||
return newTemplate
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create template')
|
||||
throw err
|
||||
// Check if we have tokens available
|
||||
const accessToken = localStorage.getItem('accessToken')
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
console.log('🔐 [useTemplates] Token availability:', {
|
||||
hasAccessToken: !!accessToken,
|
||||
hasRefreshToken: !!refreshToken,
|
||||
accessTokenLength: accessToken?.length || 0,
|
||||
refreshTokenLength: refreshToken?.length || 0
|
||||
})
|
||||
}, [user, authLoading])
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [combined, setCombined] = useState<{
|
||||
data: DatabaseTemplate[];
|
||||
count?: number;
|
||||
pagination?: { total?: number; limit?: number; offset?: number; hasMore?: boolean };
|
||||
} | null>(null);
|
||||
|
||||
// Stable categories fetched once, independent of paginated data
|
||||
const [categories, setCategories] = useState<Array<{ id: string; name: string; count: number }>>([]);
|
||||
|
||||
const [paginationState, setPaginationState] = useState({
|
||||
currentPage: 0,
|
||||
pageSize: 6, // Respect backend pagination: 6 items per page
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
loading: false,
|
||||
searchQuery: '',
|
||||
selectedCategory: 'all',
|
||||
});
|
||||
|
||||
const { show } = useToast();
|
||||
|
||||
// Fetch templates with pagination, category, and search
|
||||
const fetchTemplatesWithPagination = useCallback(async (opts?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
category?: string;
|
||||
search?: string;
|
||||
resetPagination?: boolean;
|
||||
}) => {
|
||||
// Don't fetch if auth is still loading
|
||||
if (authLoading) {
|
||||
console.log('[useTemplates] Auth still loading, skipping fetch')
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updateTemplate = async (id: string, templateData: Partial<DatabaseTemplate>) => {
|
||||
try {
|
||||
const updatedTemplate = await templateService.updateTemplate(id, templateData)
|
||||
// Refresh templates after updating
|
||||
await fetchTemplates()
|
||||
return updatedTemplate
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update template')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
// Check if we have authentication tokens available
|
||||
const accessToken = localStorage.getItem('accessToken')
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
const hasTokens = !!(accessToken || refreshToken)
|
||||
|
||||
const deleteTemplate = async (id: string) => {
|
||||
try {
|
||||
await templateService.deleteTemplate(id)
|
||||
// Refresh templates after deleting
|
||||
await fetchTemplates()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete template')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
console.log('[useTemplates] Token check before API call:', {
|
||||
hasAccessToken: !!accessToken,
|
||||
hasRefreshToken: !!refreshToken,
|
||||
hasAnyToken: hasTokens,
|
||||
userExists: !!user,
|
||||
userId: user?.id
|
||||
})
|
||||
|
||||
// Convert database templates to the format expected by the UI
|
||||
const getTemplatesForUI = async () => {
|
||||
const allTemplates: Array<DatabaseTemplate & {
|
||||
features: string[]
|
||||
complexity: number
|
||||
timeEstimate: string
|
||||
techStack: string[]
|
||||
popularity?: number
|
||||
lastUpdated?: string
|
||||
}> = []
|
||||
// If user exists but no tokens, wait a bit for tokens to be loaded
|
||||
if (user && !hasTokens) {
|
||||
console.log('[useTemplates] User exists but no tokens found, waiting for token loading...')
|
||||
// Wait a short time for tokens to be loaded from localStorage
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
for (const [category, categoryTemplates] of Object.entries(templates)) {
|
||||
for (const template of categoryTemplates) {
|
||||
try {
|
||||
// Fetch features for this template
|
||||
const features = await templateService.getFeaturesForTemplate(template.id)
|
||||
const featureNames = features.map(f => f.name)
|
||||
// Check again after waiting
|
||||
const retryAccessToken = localStorage.getItem('accessToken')
|
||||
const retryRefreshToken = localStorage.getItem('refreshToken')
|
||||
const retryHasTokens = !!(retryAccessToken || retryRefreshToken)
|
||||
|
||||
// Convert database template to UI format
|
||||
const uiTemplate = {
|
||||
...template,
|
||||
features: featureNames, // Use actual features from API
|
||||
complexity: 3, // Default complexity
|
||||
timeEstimate: "2-4 weeks", // Default time estimate
|
||||
techStack: ["Next.js", "PostgreSQL", "Tailwind CSS"], // Default tech stack
|
||||
popularity: template.avg_rating ? Math.round(template.avg_rating * 20) : 75, // Convert rating to popularity
|
||||
lastUpdated: template.updated_at ? new Date(template.updated_at).toISOString().split('T')[0] : undefined,
|
||||
featureCount: featureNames.length
|
||||
}
|
||||
allTemplates.push(uiTemplate)
|
||||
} catch (error) {
|
||||
console.error(`Error fetching features for template ${template.id}:`, error)
|
||||
// Fallback with empty features
|
||||
const uiTemplate = {
|
||||
...template,
|
||||
features: [],
|
||||
complexity: 3,
|
||||
timeEstimate: "2-4 weeks",
|
||||
techStack: ["Next.js", "PostgreSQL", "Tailwind CSS"],
|
||||
popularity: template.avg_rating ? Math.round(template.avg_rating * 20) : 75,
|
||||
lastUpdated: template.updated_at ? new Date(template.updated_at).toISOString().split('T')[0] : undefined,
|
||||
featureCount: 0
|
||||
}
|
||||
allTemplates.push(uiTemplate)
|
||||
}
|
||||
console.log('[useTemplates] Token check after waiting:', {
|
||||
hasAccessToken: !!retryAccessToken,
|
||||
hasRefreshToken: !!retryRefreshToken,
|
||||
hasAnyToken: retryHasTokens
|
||||
})
|
||||
|
||||
if (!retryHasTokens) {
|
||||
console.log('[useTemplates] Still no tokens after waiting, skipping authenticated request')
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return allTemplates
|
||||
}
|
||||
try {
|
||||
const { page = 0, pageSize = paginationState.pageSize, category = paginationState.selectedCategory, search = paginationState.searchQuery, resetPagination = false } = opts || {};
|
||||
|
||||
const offset = resetPagination ? 0 : page * pageSize;
|
||||
const limit = pageSize; // Use pageSize for limit
|
||||
|
||||
setPaginationState((prev) => ({ ...prev, loading: true }));
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
console.log('[useTemplates] Fetching templates with params:', { page, pageSize, category, search, offset });
|
||||
console.log('[useTemplates] User ID for API call:', user?.id);
|
||||
console.log('[useTemplates] User object:', user);
|
||||
console.log('[useTemplates] includeOthers will be:', !user?.id);
|
||||
|
||||
// Only pass userId if it is a valid UUID v4; otherwise, omit and include others
|
||||
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const validUserId = typeof user?.id === 'string' && uuidV4Regex.test(user.id) ? user.id : undefined;
|
||||
if (!validUserId && user?.id) {
|
||||
console.warn('[useTemplates] user.id is not a valid UUID v4; omitting userId to avoid backend filter. user.id:', user.id)
|
||||
}
|
||||
|
||||
const res = await templateService.getCombinedTemplates({
|
||||
userId: validUserId,
|
||||
includeOthers: true, // always include custom templates from others + defaults
|
||||
limit: pageSize,
|
||||
offset,
|
||||
category: category === 'all' ? undefined : category,
|
||||
search: search || undefined,
|
||||
});
|
||||
|
||||
console.log('[useTemplates] API call parameters:', {
|
||||
userId: user?.id,
|
||||
includeOthers: !user?.id,
|
||||
userExists: !!user,
|
||||
userType: typeof user
|
||||
});
|
||||
|
||||
console.log('[useTemplates] API response received:', {
|
||||
hasData: !!res?.data,
|
||||
dataLength: res?.data?.length || 0,
|
||||
count: res?.count,
|
||||
pagination: res?.pagination,
|
||||
fullResponse: res,
|
||||
templates: res?.data?.map(t => ({ id: t.id, title: t.title, type: t.type, category: t.category }))
|
||||
});
|
||||
|
||||
setPaginationState((prev) => ({
|
||||
...prev,
|
||||
currentPage: resetPagination ? 0 : page,
|
||||
pageSize,
|
||||
total: res.pagination?.total || 0,
|
||||
hasMore: res.pagination?.hasMore || false,
|
||||
loading: false,
|
||||
searchQuery: search,
|
||||
selectedCategory: category,
|
||||
}));
|
||||
|
||||
setCombined(res);
|
||||
setLoading(false);
|
||||
|
||||
return res;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch templates';
|
||||
console.error('[useTemplates] Error fetching templates:', err);
|
||||
|
||||
// Handle authentication errors gracefully without redirecting
|
||||
if (errorMessage.includes('Authentication required') || errorMessage.includes('Access denied')) {
|
||||
setError('Please sign in to view your templates. You can still browse public templates.');
|
||||
// Don't throw the error to prevent further processing
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
}
|
||||
|
||||
setPaginationState((prev) => ({ ...prev, loading: false }));
|
||||
setLoading(false);
|
||||
}
|
||||
}, [paginationState.pageSize, paginationState.selectedCategory, paginationState.searchQuery, user?.id, authLoading]);
|
||||
|
||||
// Load more templates for pagination
|
||||
const loadMoreTemplates = useCallback(async () => {
|
||||
if (paginationState.loading || !paginationState.hasMore) {
|
||||
console.log('[useTemplates] Skipping loadMoreTemplates:', { loading: paginationState.loading, hasMore: paginationState.hasMore });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextPage = paginationState.currentPage + 1;
|
||||
const res = await fetchTemplatesWithPagination({
|
||||
page: nextPage,
|
||||
pageSize: paginationState.pageSize,
|
||||
category: paginationState.selectedCategory,
|
||||
search: paginationState.searchQuery,
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
console.error('[useTemplates] Error loading more templates:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [user, paginationState.loading, paginationState.hasMore, paginationState.currentPage, paginationState.pageSize, paginationState.selectedCategory, paginationState.searchQuery, fetchTemplatesWithPagination]);
|
||||
|
||||
// Initial fetch on mount - wait for user to be loaded or confirmed as null
|
||||
useEffect(() => {
|
||||
if (authLoading) {
|
||||
console.log('[useTemplates] Auth still loading, waiting...')
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useTemplates] Initiating fetch, user:', user?.id || 'anonymous')
|
||||
|
||||
// Determine what to fetch based on user state
|
||||
let fetchParams = {
|
||||
page: 0,
|
||||
pageSize: paginationState.pageSize,
|
||||
category: paginationState.selectedCategory,
|
||||
search: paginationState.searchQuery,
|
||||
resetPagination: true,
|
||||
};
|
||||
|
||||
// If no user, only fetch public templates
|
||||
if (!user?.id) {
|
||||
console.log('[useTemplates] No user, fetching only public templates')
|
||||
fetchParams = {
|
||||
...fetchParams,
|
||||
category: 'all', // Force 'all' category for public templates
|
||||
search: '',
|
||||
};
|
||||
}
|
||||
|
||||
fetchTemplatesWithPagination(fetchParams).catch((err) => {
|
||||
console.error('[useTemplates] Fetch failed:', err)
|
||||
setLoading(false)
|
||||
});
|
||||
}, [authLoading, user?.id]); // Depend on both authLoading and user?.id
|
||||
|
||||
// Fetch categories once (does not affect pagination)
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const byCat: TemplatesByCategory = await templateService.getTemplatesByCategory();
|
||||
const entries = Object.entries(byCat || {});
|
||||
const total = entries.reduce((sum, [, arr]) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
|
||||
const cats = [
|
||||
{ id: 'all', name: 'All Templates', count: total },
|
||||
...entries.map(([key, arr]) => ({ id: key, name: key, count: (arr || []).length })),
|
||||
];
|
||||
setCategories(cats);
|
||||
} catch (e) {
|
||||
console.warn('[useTemplates] Failed to load categories; falling back to derived list.', e);
|
||||
setCategories([{ id: 'all', name: 'All Templates', count: combined?.pagination?.total || 0 }]);
|
||||
}
|
||||
};
|
||||
loadCategories();
|
||||
}, [combined?.pagination?.total]);
|
||||
|
||||
// CRUD operations
|
||||
const createTemplate = async (templateData: Partial<DatabaseTemplate>) => {
|
||||
try {
|
||||
const newTemplate = await templateService.createTemplate(templateData);
|
||||
// Refresh templates after creation
|
||||
await fetchTemplatesWithPagination({ resetPagination: true });
|
||||
return newTemplate;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create template';
|
||||
// Emit a browser toast using the simplest possible fallback
|
||||
show({ title: 'Duplicate template', description: errorMessage, variant: 'error' });
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const updateTemplate = async (id: string, templateData: Partial<DatabaseTemplate>, isCustom: boolean = false) => {
|
||||
try {
|
||||
const updatedTemplate = await templateService.updateTemplate(id, templateData, isCustom);
|
||||
// Refresh templates after update
|
||||
await fetchTemplatesWithPagination({ resetPagination: true });
|
||||
show({
|
||||
title: 'Template updated',
|
||||
description: (templateData as Record<string, unknown>)?.title ? `Updated: ${(templateData as Record<string, unknown>).title}` : undefined,
|
||||
variant: 'success'
|
||||
});
|
||||
return updatedTemplate;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update template';
|
||||
show({ title: 'Update failed', description: errorMessage, variant: 'error' });
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTemplate = async (id: string, isCustom: boolean = false) => {
|
||||
try {
|
||||
await templateService.deleteTemplate(id, isCustom);
|
||||
// Refresh templates after deletion
|
||||
await fetchTemplatesWithPagination({ resetPagination: true });
|
||||
show({ title: 'Template deleted', variant: 'success' });
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to delete template';
|
||||
show({ title: 'Delete failed', description: errorMessage, variant: 'error' });
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Get template features when a template is selected
|
||||
const getTemplateFeatures = async (templateId: string): Promise<string[]> => {
|
||||
try {
|
||||
const features = await templateService.getFeaturesForTemplate(templateId)
|
||||
return features.map(feature => feature.name)
|
||||
const features = await templateService.getFeaturesForTemplate(templateId);
|
||||
return features.map((feature) => feature.name);
|
||||
} catch (error) {
|
||||
console.error('Error fetching template features:', error)
|
||||
return []
|
||||
console.error('[useTemplates] Error fetching template features:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Feature CRUD for a template
|
||||
const fetchFeatures = async (templateId: string): Promise<TemplateFeature[]> => {
|
||||
return templateService.getFeaturesForTemplate(templateId)
|
||||
}
|
||||
return templateService.getFeaturesForTemplate(templateId);
|
||||
};
|
||||
|
||||
const createFeature = async (templateId: string, feature: Partial<TemplateFeature>) => {
|
||||
const payload = { ...feature, template_id: templateId }
|
||||
return templateService.createFeature(payload)
|
||||
}
|
||||
const payload = { ...feature, template_id: templateId };
|
||||
return templateService.createFeature(payload);
|
||||
};
|
||||
|
||||
const updateFeature = async (
|
||||
featureId: string,
|
||||
updates: Partial<TemplateFeature> & { isCustom?: boolean }
|
||||
) => {
|
||||
// If updates indicate custom, route accordingly
|
||||
return templateService.updateFeature(featureId, updates)
|
||||
}
|
||||
const updateFeature = async (featureId: string, updates: Partial<TemplateFeature> & { isCustom?: boolean }) => {
|
||||
return templateService.updateFeature(featureId, updates);
|
||||
};
|
||||
|
||||
const deleteFeature = async (featureId: string, opts?: { isCustom?: boolean }) => {
|
||||
return templateService.deleteFeature(featureId, opts)
|
||||
}
|
||||
return templateService.deleteFeature(featureId, opts);
|
||||
};
|
||||
|
||||
return {
|
||||
templates,
|
||||
loading,
|
||||
user,
|
||||
combined,
|
||||
loading: loading || authLoading,
|
||||
error,
|
||||
fetchTemplates,
|
||||
paginationState,
|
||||
categories,
|
||||
fetchTemplatesWithPagination,
|
||||
loadMoreTemplates,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
getTemplatesForUI,
|
||||
getTemplateFeatures,
|
||||
fetchFeatures,
|
||||
createFeature,
|
||||
updateFeature,
|
||||
deleteFeature
|
||||
}
|
||||
deleteFeature,
|
||||
};
|
||||
}
|
||||
192
src/lib/api-config.ts
Normal file
192
src/lib/api-config.ts
Normal file
@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Centralized API configuration for the CodeNuk frontend
|
||||
* All API base URLs are managed from this single location
|
||||
*/
|
||||
|
||||
// Default API base URL - can be overridden by environment variables
|
||||
const DEFAULT_API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
// Environment-based configuration
|
||||
export const API_CONFIG = {
|
||||
// Main API base URL
|
||||
BASE_URL: process.env.NEXT_PUBLIC_API_URL || DEFAULT_API_BASE_URL,
|
||||
|
||||
// Specific service endpoints (if needed in the future)
|
||||
AUTH_SERVICE: process.env.NEXT_PUBLIC_AUTH_API_URL || DEFAULT_API_BASE_URL,
|
||||
TEMPLATE_SERVICE: process.env.NEXT_PUBLIC_TEMPLATE_MANAGER_URL || DEFAULT_API_BASE_URL,
|
||||
GENERATION_SERVICE: process.env.NEXT_PUBLIC_GENERATION_SERVICE_URL || DEFAULT_API_BASE_URL,
|
||||
SELECTION_SERVICE: process.env.NEXT_PUBLIC_SELECTION_SERVICE_URL || DEFAULT_API_BASE_URL,
|
||||
|
||||
// AI Mockup Wireframe Service via API Gateway
|
||||
// Route through gateway on port 8000 to unify CORS/auth and service access
|
||||
AI_MOCKUP_SERVICE: process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000/api/mockup',
|
||||
USER_AUTH_SERVICE: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8000/api/auth',
|
||||
} as const;
|
||||
|
||||
// AI Mockup Configuration
|
||||
export const AI_MOCKUP_CONFIG = {
|
||||
// Backend API configuration
|
||||
backend: {
|
||||
baseUrl: API_CONFIG.AI_MOCKUP_SERVICE,
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
generateWireframe: '/generate-wireframe',
|
||||
generateWireframeDesktop: '/generate-wireframe/desktop',
|
||||
generateWireframeTablet: '/generate-wireframe/tablet',
|
||||
generateWireframeMobile: '/generate-wireframe/mobile',
|
||||
generateAllDevices: '/generate-all-devices',
|
||||
wireframes: '/api/wireframes',
|
||||
wireframe: (id?: string) => id ? `/api/wireframes/${id}` : '/api/wireframes',
|
||||
},
|
||||
timeout: 30000, // 30 seconds
|
||||
},
|
||||
|
||||
// User Authentication Service
|
||||
auth: {
|
||||
baseUrl: API_CONFIG.USER_AUTH_SERVICE,
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
register: '/api/auth/register',
|
||||
login: '/api/auth/login',
|
||||
logout: '/api/auth/logout',
|
||||
refresh: '/api/auth/refresh',
|
||||
profile: '/api/auth/me',
|
||||
preferences: '/api/auth/preferences',
|
||||
projects: '/api/auth/projects',
|
||||
},
|
||||
tokenKey: 'auth_token',
|
||||
refreshTokenKey: 'refresh_token',
|
||||
},
|
||||
|
||||
// UI configuration
|
||||
ui: {
|
||||
maxPromptLength: 1000,
|
||||
statusCheckInterval: 10000, // 10 seconds
|
||||
generationTimeout: 30000, // 30 seconds
|
||||
},
|
||||
|
||||
// Wireframe defaults
|
||||
wireframe: {
|
||||
defaultPageSize: { width: 1200, height: 800 },
|
||||
defaultSpacing: { gap: 16, padding: 20 },
|
||||
minElementSize: { width: 80, height: 40 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Export the main API base URL for backward compatibility
|
||||
export const API_BASE_URL = API_CONFIG.BASE_URL;
|
||||
|
||||
// Temporary debug logging
|
||||
console.log('🔧 API Config Debug:', {
|
||||
'process.env.NEXT_PUBLIC_API_URL': process.env.NEXT_PUBLIC_API_URL,
|
||||
'process.env.NEXT_PUBLIC_AUTH_API_URL': process.env.NEXT_PUBLIC_AUTH_API_URL,
|
||||
'DEFAULT_API_BASE_URL': DEFAULT_API_BASE_URL,
|
||||
'API_CONFIG.BASE_URL': API_CONFIG.BASE_URL,
|
||||
'API_CONFIG.AUTH_SERVICE': API_CONFIG.AUTH_SERVICE,
|
||||
'API_BASE_URL': API_BASE_URL
|
||||
});
|
||||
|
||||
// Helper function to get full API endpoint URL
|
||||
export const getApiUrl = (endpoint: string): string => {
|
||||
// Remove leading slash if present to avoid double slashes
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
|
||||
return `${API_CONFIG.BASE_URL}/${cleanEndpoint}`;
|
||||
};
|
||||
|
||||
// Helper function to get service-specific URL
|
||||
export const getServiceUrl = (service: keyof typeof API_CONFIG, endpoint: string): string => {
|
||||
const baseUrl = API_CONFIG[service];
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
|
||||
return `${baseUrl}/${cleanEndpoint}`;
|
||||
};
|
||||
|
||||
// AI Mockup Helper Functions
|
||||
export const getAIMockupApiUrl = (endpoint: string): string => {
|
||||
return `${AI_MOCKUP_CONFIG.backend.baseUrl}${endpoint}`;
|
||||
};
|
||||
|
||||
export const getAIMockupAuthUrl = (endpoint: string): string => {
|
||||
return `${AI_MOCKUP_CONFIG.auth.baseUrl}${endpoint}`;
|
||||
};
|
||||
|
||||
// Helper function to get health check URL
|
||||
export const getAIMockupHealthUrl = (): string => {
|
||||
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.health);
|
||||
};
|
||||
|
||||
// Helper function to get auth health check URL
|
||||
export const getAIMockupAuthHealthUrl = (): string => {
|
||||
return getAIMockupAuthUrl(AI_MOCKUP_CONFIG.auth.endpoints.health);
|
||||
};
|
||||
|
||||
// Helper function to get wireframe generation URL for specific device
|
||||
export const getWireframeGenerationUrl = (device: 'desktop' | 'tablet' | 'mobile' = 'desktop'): string => {
|
||||
switch (device) {
|
||||
case 'tablet':
|
||||
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.generateWireframeTablet);
|
||||
case 'mobile':
|
||||
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.generateWireframeMobile);
|
||||
case 'desktop':
|
||||
default:
|
||||
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.generateWireframeDesktop);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get universal wireframe generation URL (backward compatibility)
|
||||
export const getUniversalWireframeGenerationUrl = (): string => {
|
||||
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.generateWireframe);
|
||||
};
|
||||
|
||||
// Helper function to get all devices generation URL
|
||||
export const getAllDevicesGenerationUrl = (): string => {
|
||||
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.generateAllDevices);
|
||||
};
|
||||
|
||||
// Helper function to get wireframe persistence URLs
|
||||
export const getWireframeUrl = (id?: string): string => {
|
||||
if (id) {
|
||||
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.wireframe(id));
|
||||
}
|
||||
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.wireframes);
|
||||
};
|
||||
|
||||
// Helper function to get wireframe by ID URL
|
||||
export const getWireframeByIdUrl = (id: string): string => {
|
||||
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.wireframe(id));
|
||||
};
|
||||
|
||||
// Authentication helper functions - Use consistent token retrieval
|
||||
export const getAIMockupAuthHeaders = (): HeadersInit => {
|
||||
// Use the main auth system's token instead of separate token
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
|
||||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
export const getAIMockupAuthHeadersWithContentType = (): HeadersInit => {
|
||||
// Use the main auth system's token instead of separate token
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
};
|
||||
|
||||
export const isAIMockupAuthenticated = (): boolean => {
|
||||
// Use the main auth system's token instead of separate token
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
|
||||
return !!token;
|
||||
};
|
||||
|
||||
export const getCurrentAIMockupUser = (): any => {
|
||||
try {
|
||||
// Use the main auth system's token instead of separate token
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
|
||||
if (!token) return null;
|
||||
|
||||
// Decode JWT token to get user info
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@ -5,18 +5,23 @@ import {
|
||||
FeatureReviewData,
|
||||
AdminStats,
|
||||
FeatureSynonym,
|
||||
AdminApiResponse
|
||||
AdminApiResponse,
|
||||
AdminTemplate
|
||||
} from '@/types/admin.types';
|
||||
import { getAccessToken } from '@/components/apis/authApiClients';
|
||||
import { BACKEND_URL } from '@/config/backend';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_TEMPLATE_MANAGER_URL || 'http://localhost:8009';
|
||||
const API_BASE_URL = BACKEND_URL;
|
||||
const AUTH_API_BASE_URL = BACKEND_URL;
|
||||
|
||||
// Helper function to make API calls
|
||||
async function apiCall<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
options: RequestInit = {},
|
||||
useAuthAPI = false
|
||||
): Promise<AdminApiResponse<T>> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const baseUrl = useAuthAPI ? AUTH_API_BASE_URL : API_BASE_URL;
|
||||
const url = `${baseUrl}${endpoint}`;
|
||||
|
||||
const token = getAccessToken();
|
||||
const defaultHeaders: Record<string, string> = {
|
||||
@ -36,7 +41,14 @@ async function apiCall<T>(
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
const err = new AdminApiError(
|
||||
errorData.message || `HTTP error! status: ${response.status}`,
|
||||
response.status,
|
||||
errorData.code
|
||||
);
|
||||
// Attach full server payload for consumers that need richer context
|
||||
(err as AdminApiError & { data: Record<string, unknown> }).data = errorData;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@ -46,42 +58,42 @@ async function apiCall<T>(
|
||||
export const adminApi = {
|
||||
// Get pending features
|
||||
getPendingFeatures: async (limit = 50, offset = 0): Promise<AdminFeature[]> => {
|
||||
const response = await apiCall<AdminFeature[]>(`/api/admin/features/pending?limit=${limit}&offset=${offset}`);
|
||||
const response = await apiCall<AdminFeature[]>(`/api/auth/admin/custom-features?status=pending&limit=${limit}&offset=${offset}`, {}, true);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get features by status
|
||||
getFeaturesByStatus: async (status: string, limit = 50, offset = 0): Promise<AdminFeature[]> => {
|
||||
const response = await apiCall<AdminFeature[]>(`/api/admin/features/status/${status}?limit=${limit}&offset=${offset}`);
|
||||
const response = await apiCall<AdminFeature[]>(`/api/auth/admin/custom-features?status=${status}&limit=${limit}&offset=${offset}`, {}, true);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get feature statistics
|
||||
getFeatureStats: async (): Promise<AdminStats> => {
|
||||
const response = await apiCall<AdminStats>('/api/admin/features/stats');
|
||||
const response = await apiCall<AdminStats>('/api/auth/admin/custom-features', {}, true);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Review a feature
|
||||
reviewFeature: async (featureId: string, reviewData: FeatureReviewData): Promise<AdminFeature> => {
|
||||
const response = await apiCall<AdminFeature>(`/api/admin/features/${featureId}/review`, {
|
||||
method: 'POST',
|
||||
const response = await apiCall<AdminFeature>(`/api/auth/admin/custom-features/${featureId}/review`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(reviewData),
|
||||
});
|
||||
}, true);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Find similar features
|
||||
findSimilarFeatures: async (query: string, threshold = 0.7, limit = 5): Promise<FeatureSimilarity[]> => {
|
||||
const response = await apiCall<FeatureSimilarity[]>(
|
||||
`/api/admin/features/similar?q=${encodeURIComponent(query)}&threshold=${threshold}&limit=${limit}`
|
||||
`/api/features/similar?q=${encodeURIComponent(query)}&threshold=${threshold}&limit=${limit}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Add feature synonym
|
||||
addFeatureSynonym: async (featureId: string, synonym: string, createdBy?: string): Promise<FeatureSynonym> => {
|
||||
const response = await apiCall<FeatureSynonym>(`/api/admin/features/${featureId}/synonyms`, {
|
||||
const response = await apiCall<FeatureSynonym>(`/api/features/${featureId}/synonyms`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ synonym, created_by: createdBy }),
|
||||
});
|
||||
@ -90,7 +102,7 @@ export const adminApi = {
|
||||
|
||||
// Get feature synonyms
|
||||
getFeatureSynonyms: async (featureId: string): Promise<FeatureSynonym[]> => {
|
||||
const response = await apiCall<FeatureSynonym[]>(`/api/admin/features/${featureId}/synonyms`);
|
||||
const response = await apiCall<FeatureSynonym[]>(`/api/features/${featureId}/synonyms`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@ -117,6 +129,272 @@ export const adminApi = {
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get custom features (from user-auth admin API)
|
||||
getCustomFeatures: async (status?: string, limit = 50, offset = 0): Promise<AdminFeature[]> => {
|
||||
const params = new URLSearchParams({ limit: limit.toString(), offset: offset.toString() });
|
||||
if (status) params.append('status', status);
|
||||
const response = await apiCall<AdminFeature[]>(`/api/auth/admin/custom-features?${params}`, {}, true);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get custom templates (from user-auth admin API)
|
||||
getCustomTemplates: async (status?: string, limit = 50, offset = 0): Promise<AdminTemplate[]> => {
|
||||
const params = new URLSearchParams({ limit: limit.toString(), offset: offset.toString() });
|
||||
if (status) params.append('status', status);
|
||||
const response = await apiCall<AdminTemplate[]>(`/api/auth/admin/custom-templates?${params}`, {}, true);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Review custom feature (from user-auth admin API)
|
||||
reviewCustomFeature: async (featureId: string, reviewData: { status: string; admin_notes?: string }): Promise<AdminFeature> => {
|
||||
const response = await apiCall<AdminFeature>(`/api/auth/admin/custom-features/${featureId}/review`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(reviewData),
|
||||
}, true);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Review custom template (from user-auth admin API)
|
||||
reviewTemplate: async (templateId: string, reviewData: { status: string; admin_notes?: string }): Promise<Record<string, unknown>> => {
|
||||
const response = await apiCall<Record<string, unknown>>(`/api/auth/admin/custom-templates/${templateId}/review`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(reviewData),
|
||||
}, true);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Reject custom feature
|
||||
rejectCustomFeature: async (featureId: string, adminNotes?: string): Promise<AdminFeature> => {
|
||||
const response = await apiCall<AdminFeature>(`/api/auth/admin/custom-features/${featureId}/review`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
status: 'rejected',
|
||||
admin_notes: adminNotes || 'Rejected by admin'
|
||||
}),
|
||||
}, true);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Reject custom template
|
||||
rejectTemplate: async (templateId: string, adminNotes?: string): Promise<Record<string, unknown>> => {
|
||||
const response = await apiCall<Record<string, unknown>>(`/api/auth/admin/custom-templates/${templateId}/review`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
status: 'rejected',
|
||||
admin_notes: adminNotes || 'Rejected by admin'
|
||||
}),
|
||||
}, true);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update custom feature
|
||||
updateCustomFeature: async (featureId: string, updateData: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
complexity?: 'low' | 'medium' | 'high';
|
||||
business_rules?: Record<string, unknown>;
|
||||
technical_requirements?: Record<string, unknown>;
|
||||
}): Promise<AdminFeature> => {
|
||||
const response = await apiCall<AdminFeature>(`/api/features/${featureId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData),
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create new template from edit (creates new record instead of updating)
|
||||
createTemplateFromEdit: async (originalTemplateId: string, updateData: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
type?: string;
|
||||
icon?: string;
|
||||
gradient?: string;
|
||||
border?: string;
|
||||
text?: string;
|
||||
subtext?: string;
|
||||
}): Promise<Record<string, unknown>> => {
|
||||
const response = await apiCall<Record<string, unknown>>(`/api/templates`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...updateData,
|
||||
is_custom: true,
|
||||
isCustom: true,
|
||||
status: 'pending',
|
||||
created_from_edit: originalTemplateId
|
||||
}),
|
||||
}, false);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create approved template in main templates table
|
||||
createApprovedTemplate: async (customTemplateId: string, templateData: {
|
||||
title: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
type: string;
|
||||
icon?: string;
|
||||
gradient?: string;
|
||||
border?: string;
|
||||
text?: string;
|
||||
subtext?: string;
|
||||
}): Promise<Record<string, unknown>> => {
|
||||
// Use dedicated endpoint that also approves the custom template server-side
|
||||
const response = await apiCall<Record<string, unknown>>(`/api/templates/approve-custom`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
custom_template_id: customTemplateId,
|
||||
template: {
|
||||
...templateData,
|
||||
is_custom: false,
|
||||
isCustom: false
|
||||
}
|
||||
}),
|
||||
}, false);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create feature directly in template_features table
|
||||
createApprovedFeature: async (featureData: {
|
||||
template_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
complexity: 'low' | 'medium' | 'high';
|
||||
business_rules?: Record<string, unknown>;
|
||||
technical_requirements?: Record<string, unknown>;
|
||||
feature_type?: string;
|
||||
}): Promise<AdminFeature> => {
|
||||
const response = await apiCall<AdminFeature>(`/api/features`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(featureData),
|
||||
}, false);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single custom feature by ID
|
||||
getCustomFeature: async (featureId: string): Promise<AdminFeature> => {
|
||||
const response = await apiCall<AdminFeature>(`/api/features/${featureId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single custom template by ID
|
||||
getCustomTemplate: async (templateId: string): Promise<Record<string, unknown>> => {
|
||||
const response = await apiCall<Record<string, unknown>>(`/api/templates/${templateId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get custom feature stats
|
||||
getCustomFeatureStats: async (): Promise<AdminStats> => {
|
||||
const response = await apiCall<AdminStats>('/api/admin/features/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get template stats
|
||||
getTemplateStats: async (): Promise<AdminStats> => {
|
||||
const response = await apiCall<AdminStats>('/api/admin/templates/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create new custom template
|
||||
createCustomTemplate: async (templateData: {
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
type: string;
|
||||
complexity: string;
|
||||
icon?: string;
|
||||
gradient?: string;
|
||||
border?: string;
|
||||
text?: string;
|
||||
subtext?: string;
|
||||
}): Promise<Record<string, unknown>> => {
|
||||
const response = await apiCall<Record<string, unknown>>('/api/templates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...templateData
|
||||
}),
|
||||
}, false);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Template Features Management
|
||||
// Get features for a specific template
|
||||
getTemplateFeatures: async (templateId: string): Promise<AdminFeature[]> => {
|
||||
const response = await apiCall<AdminFeature[]>(`/api/templates/${templateId}/features`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Add feature to template
|
||||
addFeatureToTemplate: async (templateId: string, featureData: {
|
||||
name: string;
|
||||
description?: string;
|
||||
complexity: 'low' | 'medium' | 'high';
|
||||
business_rules?: Record<string, unknown>;
|
||||
technical_requirements?: Record<string, unknown>;
|
||||
feature_type?: string;
|
||||
}): Promise<AdminFeature> => {
|
||||
const response = await apiCall<AdminFeature>(`/api/templates/${templateId}/features`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(featureData),
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update feature in template
|
||||
updateTemplateFeature: async (templateId: string, featureId: string, updateData: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
complexity?: 'low' | 'medium' | 'high';
|
||||
business_rules?: Record<string, unknown>;
|
||||
technical_requirements?: Record<string, unknown>;
|
||||
feature_type?: string;
|
||||
}): Promise<AdminFeature> => {
|
||||
const response = await apiCall<AdminFeature>(`/api/templates/${templateId}/features/${featureId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData),
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Remove feature from template
|
||||
removeFeatureFromTemplate: async (templateId: string, featureId: string): Promise<void> => {
|
||||
await apiCall<void>(`/api/templates/${templateId}/features/${featureId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
// Bulk add features to template
|
||||
bulkAddFeaturesToTemplate: async (templateId: string, features: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
complexity: 'low' | 'medium' | 'high';
|
||||
business_rules?: Record<string, unknown>;
|
||||
technical_requirements?: Record<string, unknown>;
|
||||
feature_type?: string;
|
||||
}>): Promise<AdminFeature[]> => {
|
||||
const response = await apiCall<AdminFeature[]>(`/api/templates/${templateId}/features/bulk`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ features }),
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin Templates Management (from main templates table)
|
||||
// Get all admin templates for management
|
||||
getAdminTemplates: async (limit = 50, offset = 0, category?: string, search?: string): Promise<AdminTemplate[]> => {
|
||||
const params = new URLSearchParams({ limit: limit.toString(), offset: offset.toString() });
|
||||
if (category && category !== 'all') params.append('category', category);
|
||||
if (search) params.append('search', search);
|
||||
const response = await apiCall<AdminTemplate[]>(`/api/admin/templates?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get admin template statistics
|
||||
getAdminTemplateStats: async (): Promise<AdminStats> => {
|
||||
const response = await apiCall<AdminStats>('/api/admin/templates/stats');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Feature submission API (for regular users)
|
||||
@ -127,10 +405,10 @@ export const featureApi = {
|
||||
name: string;
|
||||
description?: string;
|
||||
complexity: 'low' | 'medium' | 'high';
|
||||
business_rules?: any;
|
||||
technical_requirements?: any;
|
||||
business_rules?: Record<string, unknown>;
|
||||
technical_requirements?: Record<string, unknown>;
|
||||
created_by_user_session?: string;
|
||||
}): Promise<{ data: AdminFeature; similarityInfo?: any }> => {
|
||||
}): Promise<{ data: AdminFeature; similarityInfo?: Record<string, unknown> }> => {
|
||||
const response = await apiCall<AdminFeature>('/api/features/custom', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(featureData),
|
||||
@ -138,7 +416,7 @@ export const featureApi = {
|
||||
|
||||
return {
|
||||
data: response.data,
|
||||
similarityInfo: (response as any).similarityInfo,
|
||||
similarityInfo: (response as AdminApiResponse<AdminFeature> & { similarityInfo?: Record<string, unknown> }).similarityInfo,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ export const config = {
|
||||
|
||||
// User Authentication Service
|
||||
auth: {
|
||||
baseUrl: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8011',
|
||||
baseUrl: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8000',
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
register: '/api/auth/register',
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { getAccessToken } from '@/components/apis/authApiClients'
|
||||
import { BACKEND_URL } from '@/config/backend'
|
||||
|
||||
export interface DatabaseTemplate {
|
||||
id: string
|
||||
type: string
|
||||
@ -14,6 +17,8 @@ export interface DatabaseTemplate {
|
||||
updated_at: string
|
||||
feature_count?: number
|
||||
avg_rating?: number
|
||||
is_custom?: boolean // Add this field to identify custom templates
|
||||
created_by_user?: string // Add this field for user-created templates
|
||||
}
|
||||
|
||||
export interface TemplateFeature {
|
||||
@ -41,19 +46,36 @@ export interface TemplatesByCategory {
|
||||
[category: string]: DatabaseTemplate[]
|
||||
}
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8009'
|
||||
|
||||
|
||||
class TemplateService {
|
||||
private async makeRequest<T>(endpoint: string): Promise<T> {
|
||||
private async makeRequest<T>(endpoint: string, requireAuth: boolean = false): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Add authentication header if required and token exists
|
||||
if (requireAuth) {
|
||||
const token = getAccessToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${endpoint}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle authentication errors gracefully
|
||||
if (response.status === 401) {
|
||||
throw new Error('Authentication required. Please sign in to continue.')
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new Error('Access denied. You do not have permission to view this resource.')
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
@ -71,29 +93,274 @@ class TemplateService {
|
||||
}
|
||||
|
||||
async getTemplatesByCategory(): Promise<TemplatesByCategory> {
|
||||
return this.makeRequest<TemplatesByCategory>('/api/templates')
|
||||
return this.makeRequest<TemplatesByCategory>('/api/templates', false)
|
||||
}
|
||||
|
||||
async getTemplateById(id: string): Promise<TemplateWithFeatures> {
|
||||
return this.makeRequest<TemplateWithFeatures>(`/api/templates/${id}`)
|
||||
return this.makeRequest<TemplateWithFeatures>(`/api/templates/${id}`, false)
|
||||
}
|
||||
|
||||
async getTemplateByType(type: string): Promise<TemplateWithFeatures> {
|
||||
return this.makeRequest<TemplateWithFeatures>(`/api/templates/type/${type}`)
|
||||
return this.makeRequest<TemplateWithFeatures>(`/api/templates/type/${type}`, false)
|
||||
}
|
||||
|
||||
async getCombinedTemplates(params?: {
|
||||
userId?: string
|
||||
status?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
category?: string | null
|
||||
search?: string | null
|
||||
includeOthers?: boolean
|
||||
}): Promise<{ data: DatabaseTemplate[]; count?: number; pagination?: { total?: number; limit?: number; offset?: number; hasMore?: boolean } }> {
|
||||
const limit = params?.limit ?? 3
|
||||
const offset = params?.offset ?? 0
|
||||
const qp: string[] = [
|
||||
`limit=${encodeURIComponent(String(limit))}`,
|
||||
`offset=${encodeURIComponent(String(offset))}`,
|
||||
]
|
||||
|
||||
// Use the /merged endpoint for templates
|
||||
const endpoint = '/api/templates/merged'
|
||||
|
||||
if (params?.category && params.category !== 'all') {
|
||||
qp.push(`category=${encodeURIComponent(params.category)}`)
|
||||
}
|
||||
|
||||
if (params?.search && params.search.trim()) {
|
||||
qp.push(`search=${encodeURIComponent(params.search.trim())}`)
|
||||
}
|
||||
|
||||
if (params?.userId) {
|
||||
qp.push(`userId=${encodeURIComponent(params.userId)}`)
|
||||
} else if (params?.includeOthers === true) {
|
||||
qp.push(`includeOthers=true`)
|
||||
}
|
||||
|
||||
const url = `${BACKEND_URL}${endpoint}?${qp.join('&')}`
|
||||
console.log('[templateService.getCombinedTemplates] Making request to URL:', url)
|
||||
console.log('[templateService.getCombinedTemplates] Request params:', params)
|
||||
console.log('🆔 [templateService] User ID being sent:', params?.userId || 'No user ID')
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Add authentication header if user ID is provided (authenticated request)
|
||||
if (params?.userId) {
|
||||
const token = getAccessToken()
|
||||
console.log('🔐 [templateService] Token check:', {
|
||||
hasUserId: !!params.userId,
|
||||
hasToken: !!token,
|
||||
tokenLength: token?.length || 0,
|
||||
tokenStart: token?.substring(0, 20) + '...' || 'No token',
|
||||
timestamp: new Date().toISOString(),
|
||||
url: url
|
||||
})
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
console.log('🔐 [templateService] Authorization header added with token')
|
||||
console.log('🔐 [templateService] Token format check:', {
|
||||
tokenType: typeof token,
|
||||
tokenLength: token.length,
|
||||
startsWithBearer: token.startsWith('Bearer'),
|
||||
firstChars: token.substring(0, 10),
|
||||
lastChars: token.substring(token.length - 10)
|
||||
})
|
||||
} else {
|
||||
console.log('❌ [templateService] No token available for authenticated request')
|
||||
}
|
||||
} else {
|
||||
console.log('🔐 [templateService] No userId provided, making public request')
|
||||
}
|
||||
|
||||
console.log('🔐 [templateService] Final headers being sent:', headers)
|
||||
console.log('🔐 [templateService] Authorization header value:', headers['Authorization'])
|
||||
|
||||
const resp = await fetch(url, { headers })
|
||||
console.log('[templateService.getCombinedTemplates] Response status:', resp.status, resp.statusText)
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '')
|
||||
console.error('[templateService.getCombinedTemplates] HTTP error', resp.status, text)
|
||||
|
||||
// Handle authentication errors gracefully
|
||||
if (resp.status === 401) {
|
||||
throw new Error('Authentication required. Please sign in to continue.')
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
throw new Error('Access denied. You do not have permission to view this resource.')
|
||||
}
|
||||
|
||||
// If combined endpoint returns 404, throw error
|
||||
if (resp.status === 404) {
|
||||
throw new Error(`/api/templates/combined endpoint not found. Please ensure this endpoint exists and is working.`)
|
||||
}
|
||||
|
||||
throw new Error(`HTTP error! status: ${resp.status}`)
|
||||
}
|
||||
|
||||
const json = await resp.json()
|
||||
console.log('[templateService.getCombinedTemplates] Raw JSON:', json)
|
||||
|
||||
// Handle merged endpoint response
|
||||
console.log('[templateService.getCombinedTemplates] Response structure:', {
|
||||
success: json?.success,
|
||||
hasData: !!json?.data,
|
||||
dataLength: json?.data?.length,
|
||||
hasPagination: !!json?.pagination,
|
||||
pagination: json?.pagination
|
||||
})
|
||||
|
||||
// Extract data from the response
|
||||
let templates = []
|
||||
if (json?.success && json?.data) {
|
||||
if (Array.isArray(json.data)) {
|
||||
// If data is already an array
|
||||
templates = json.data
|
||||
} else if (typeof json.data === 'object') {
|
||||
// If data is an object with categories (like { "System": [...], "Marketing": [...] })
|
||||
// Flatten all categories into a single array
|
||||
for (const category in json.data) {
|
||||
if (Array.isArray(json.data[category])) {
|
||||
templates.push(...json.data[category])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(json)) {
|
||||
// Fallback: if response is directly an array
|
||||
templates = json
|
||||
}
|
||||
|
||||
// Extract pagination info
|
||||
let paginationInfo = json?.pagination || {}
|
||||
if (!paginationInfo.total && templates.length > 0) {
|
||||
// If no pagination info, calculate it from the data
|
||||
paginationInfo = {
|
||||
total: templates.length,
|
||||
offset,
|
||||
limit,
|
||||
hasMore: false // Since we got all data
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
data: templates,
|
||||
count: templates.length,
|
||||
pagination: paginationInfo
|
||||
}
|
||||
|
||||
console.log('[templateService.getCombinedTemplates] Processed result:', result)
|
||||
console.log('[templateService.getCombinedTemplates] Templates array:', templates)
|
||||
console.log('[templateService.getCombinedTemplates] Pagination info:', paginationInfo)
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('[templateService.getCombinedTemplates] Error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getTemplatesByUser(params: {
|
||||
userId: string
|
||||
status?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{
|
||||
defaultTemplates: DatabaseTemplate[]
|
||||
customTemplates: DatabaseTemplate[]
|
||||
}> {
|
||||
const { userId, status, limit = 20, offset = 0 } = params
|
||||
const queryParts: string[] = []
|
||||
if (typeof status === 'string' && status.length > 0) {
|
||||
queryParts.push(`status=${encodeURIComponent(status)}`)
|
||||
}
|
||||
queryParts.push(`limit=${encodeURIComponent(String(limit))}`)
|
||||
queryParts.push(`offset=${encodeURIComponent(String(offset))}`)
|
||||
const query = queryParts.join('&')
|
||||
const url = `${BACKEND_URL}/api/templates/by-user/${encodeURIComponent(userId)}?${query}`
|
||||
try {
|
||||
console.log('[templateService.getTemplatesByUser] URL (matching curl):', url)
|
||||
const resp = await fetch(url, { headers: { 'Content-Type': 'application/json' } })
|
||||
if (!resp.ok) throw new Error(`HTTP error! status: ${resp.status}`)
|
||||
const json = await resp.json()
|
||||
console.log('[templateService.getTemplatesByUser] Raw JSON:', json)
|
||||
// Support both wrapped {success,data} and direct payloads
|
||||
const payload = (json && json.success !== undefined) ? json.data : json
|
||||
// If payload is an array, partition by flags
|
||||
let defaultTemplates: DatabaseTemplate[] = []
|
||||
let customTemplates: DatabaseTemplate[] = []
|
||||
if (Array.isArray(payload)) {
|
||||
const arr = payload as DatabaseTemplate[]
|
||||
defaultTemplates = arr.filter((t: DatabaseTemplate) => !(t?.is_custom || t?.created_by_user))
|
||||
customTemplates = arr.filter((t: DatabaseTemplate) => (t?.is_custom || t?.created_by_user))
|
||||
} else {
|
||||
// Normalize likely object shapes
|
||||
defaultTemplates = payload?.defaultTemplates || payload?.default_templates || payload?.defaults || []
|
||||
customTemplates = payload?.customTemplates || payload?.custom_templates || payload?.customs || []
|
||||
}
|
||||
const normalized = { defaultTemplates, customTemplates }
|
||||
console.log('[templateService.getTemplatesByUser] Normalized:', normalized)
|
||||
return normalized
|
||||
} catch (err) {
|
||||
console.error('[templateService.getTemplatesByUser] Error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async createTemplate(templateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> {
|
||||
console.log(templateData, "templateData___");
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/templates`, {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Add authentication header
|
||||
const token = getAccessToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// Add is_custom flag to ensure custom templates are stored in custom_templates table
|
||||
const payload = {
|
||||
...templateData,
|
||||
is_custom: true,
|
||||
isCustom: true
|
||||
};
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/templates`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
// Try to extract backend error message
|
||||
let backendMessage = ''
|
||||
let backendError = ''
|
||||
try {
|
||||
const errJson = await response.json()
|
||||
backendMessage = errJson?.message || ''
|
||||
backendError = errJson?.error || ''
|
||||
} catch {
|
||||
try {
|
||||
backendMessage = await response.text()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Handle specific error cases
|
||||
if (response.status === 401) {
|
||||
throw new Error(backendMessage || 'Authentication required. Please sign in to create templates.')
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new Error(backendMessage || 'Access denied. You do not have permission to create templates.')
|
||||
}
|
||||
if (response.status === 409) {
|
||||
// Surface duplicate-specific message from backend
|
||||
throw new Error(backendMessage || backendError || 'Template already exists with same name or type.')
|
||||
}
|
||||
throw new Error(backendMessage || `HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@ -111,7 +378,7 @@ class TemplateService {
|
||||
|
||||
// Features API
|
||||
async getFeaturesForTemplate(templateId: string): Promise<TemplateFeature[]> {
|
||||
// Use merged endpoint to include custom features
|
||||
// Use merged endpoint to include custom features (returns custom_feature.id for custom items)
|
||||
const dedupe = (items: TemplateFeature[]) => {
|
||||
const byKey = new Map<string, TemplateFeature>()
|
||||
|
||||
@ -152,8 +419,8 @@ class TemplateService {
|
||||
const merged = await this.makeRequest<TemplateFeature[]>(`/api/templates/${templateId}/features`)
|
||||
return dedupe(merged)
|
||||
} catch {
|
||||
// Fallback to default-only if merged endpoint unsupported
|
||||
const defaults = await this.makeRequest<TemplateFeature[]>(`/api/features/templates/${templateId}/merged`)
|
||||
// Fallback to compatible route in features router
|
||||
const defaults = await this.makeRequest<TemplateFeature[]>(`/api/features/templates/${templateId}/features`)
|
||||
return dedupe(defaults)
|
||||
}
|
||||
}
|
||||
@ -172,11 +439,16 @@ class TemplateService {
|
||||
async createFeature(featureData: Partial<TemplateFeature>): Promise<TemplateFeature> {
|
||||
if (
|
||||
featureData &&
|
||||
(featureData.feature_type === 'custom' || (featureData as any).feature_type === 'custom')
|
||||
(featureData.feature_type === 'custom')
|
||||
) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/features/custom`, {
|
||||
const customHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const customToken = getAccessToken()
|
||||
if (customToken) {
|
||||
customHeaders['Authorization'] = `Bearer ${customToken}`
|
||||
}
|
||||
const response = await fetch(`${BACKEND_URL}/api/features/custom`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: customHeaders,
|
||||
body: JSON.stringify(featureData),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||
@ -184,9 +456,14 @@ class TemplateService {
|
||||
if (!data.success) throw new Error(data.message || 'Failed to create custom feature')
|
||||
return data.data
|
||||
}
|
||||
const response = await fetch(`${API_BASE_URL}/api/features`, {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const token = getAccessToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
const response = await fetch(`${BACKEND_URL}/api/features`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
body: JSON.stringify(featureData),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||
@ -196,11 +473,14 @@ class TemplateService {
|
||||
}
|
||||
|
||||
async updateFeature(id: string, featureData: Partial<TemplateFeature> & { isCustom?: boolean }): Promise<TemplateFeature> {
|
||||
const isCustom = (featureData as any).isCustom || featureData.feature_type === 'custom'
|
||||
const url = isCustom ? `${API_BASE_URL}/api/features/custom/${id}` : `${API_BASE_URL}/api/features/${id}`
|
||||
const isCustom = featureData.isCustom || featureData.feature_type === 'custom'
|
||||
const url = isCustom ? `${BACKEND_URL}/api/features/custom/${id}` : `${BACKEND_URL}/api/features/${id}`
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const token = getAccessToken()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
body: JSON.stringify(featureData),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||
@ -210,28 +490,62 @@ class TemplateService {
|
||||
}
|
||||
|
||||
async deleteFeature(id: string, opts?: { isCustom?: boolean }): Promise<void> {
|
||||
const url = opts?.isCustom ? `${API_BASE_URL}/api/features/custom/${id}` : `${API_BASE_URL}/api/features/${id}`
|
||||
const url = opts?.isCustom ? `${BACKEND_URL}/api/features/custom/${id}` : `${BACKEND_URL}/api/features/${id}`
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const token = getAccessToken()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||
const data = await response.json()
|
||||
if (!data.success) throw new Error(data.message || 'Failed to delete feature')
|
||||
}
|
||||
|
||||
async updateTemplate(id: string, templateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> {
|
||||
async updateTemplate(id: string, templateData: Partial<DatabaseTemplate>, isCustom: boolean = false): Promise<DatabaseTemplate> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/templates/${id}`, {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Add authentication header
|
||||
const token = getAccessToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// Single endpoint with query flag
|
||||
const endpoint = `/api/templates/${id}?isCustom=${encodeURIComponent(String(!!isCustom))}`
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
// Try to surface backend error message
|
||||
let backendMessage = ''
|
||||
try {
|
||||
const errJson = await response.json()
|
||||
backendMessage = errJson?.message || errJson?.error || ''
|
||||
} catch {
|
||||
try {
|
||||
backendMessage = await response.text()
|
||||
} catch {}
|
||||
}
|
||||
// Handle specific error cases
|
||||
if (response.status === 401) {
|
||||
throw new Error(backendMessage || 'Authentication required. Please sign in to update templates.')
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new Error(backendMessage || 'Access denied. You do not have permission to update this template.')
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error(backendMessage || 'Template not found. It may have been deleted or you may not have access to it.')
|
||||
}
|
||||
throw new Error(backendMessage || `HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@ -247,17 +561,48 @@ class TemplateService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTemplate(id: string): Promise<void> {
|
||||
async deleteTemplate(id: string, isCustom: boolean = false): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/templates/${id}`, {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Add authentication header
|
||||
const token = getAccessToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// Single endpoint with query flag
|
||||
const endpoint = `/api/templates/${id}?isCustom=${encodeURIComponent(String(!!isCustom))}`
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
// Try to surface backend error message
|
||||
let backendMessage = ''
|
||||
try {
|
||||
const errJson = await response.json()
|
||||
backendMessage = errJson?.message || errJson?.error || ''
|
||||
} catch {
|
||||
try {
|
||||
backendMessage = await response.text()
|
||||
} catch {}
|
||||
}
|
||||
// Handle specific error cases
|
||||
if (response.status === 401) {
|
||||
throw new Error(backendMessage || 'Authentication required. Please sign in to delete templates.')
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new Error(backendMessage || 'Access denied. You do not have permission to delete this template.')
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error(backendMessage || 'Template not found. It may have been deleted or you may not have access to it.')
|
||||
}
|
||||
throw new Error(backendMessage || `HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
@ -9,13 +9,14 @@ export async function analyzeFeatureWithAI(
|
||||
featureName: string,
|
||||
description: string,
|
||||
requirements: string[],
|
||||
projectType?: string
|
||||
projectType?: string,
|
||||
userSelectedComplexity?: Complexity
|
||||
): Promise<AIAnalysisResponse> {
|
||||
try {
|
||||
const res = await fetch('/api/ai/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ featureName, description, requirements, projectType }),
|
||||
body: JSON.stringify({ featureName, description, requirements, projectType, complexity: userSelectedComplexity }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok || !json.success) {
|
||||
|
||||
@ -119,3 +119,33 @@ export interface AdminPagination {
|
||||
limit: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AdminTemplate {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
category?: string;
|
||||
gradient?: string;
|
||||
border?: string;
|
||||
text?: string;
|
||||
subtext?: string;
|
||||
complexity: 'low' | 'medium' | 'high';
|
||||
business_rules?: Record<string, unknown>;
|
||||
technical_requirements?: Record<string, unknown>;
|
||||
approved: boolean;
|
||||
usage_count: number;
|
||||
created_by_user_session?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_custom: boolean;
|
||||
// Admin approval workflow fields
|
||||
status: 'pending' | 'approved' | 'rejected' | 'duplicate';
|
||||
admin_notes?: string;
|
||||
admin_reviewed_at?: string;
|
||||
admin_reviewed_by?: string;
|
||||
canonical_template_id?: string;
|
||||
similarity_score?: number;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user