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-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@tldraw/tldraw": "^3.15.4",
|
"@tldraw/tldraw": "^3.15.4",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
@ -28,6 +29,7 @@
|
|||||||
"next": "15.4.6",
|
"next": "15.4.6",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"svg-path-parser": "^1.1.0",
|
"svg-path-parser": "^1.1.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
@ -2572,6 +2574,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@ -2809,6 +2817,66 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.11",
|
"version": "4.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
|
||||||
@ -4833,6 +4901,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.3",
|
"version": "5.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||||
@ -7061,7 +7168,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@ -8318,6 +8424,68 @@
|
|||||||
"is-arrayish": "^0.3.1"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -9074,6 +9242,35 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||||
|
|||||||
@ -3,9 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start -p 3001",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -20,6 +20,7 @@
|
|||||||
"@radix-ui/react-radio-group": "^1.3.7",
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@tldraw/tldraw": "^3.15.4",
|
"@tldraw/tldraw": "^3.15.4",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
@ -29,6 +30,7 @@
|
|||||||
"next": "15.4.6",
|
"next": "15.4.6",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"svg-path-parser": "^1.1.0",
|
"svg-path-parser": "^1.1.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zustand": "^5.0.8"
|
"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"
|
"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 { AdminDashboard } from '@/components/admin/admin-dashboard'
|
||||||
|
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
|
||||||
|
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
|
||||||
|
|
||||||
export default function AdminPage() {
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<AdminNotificationProvider>
|
||||||
<div className="text-center">
|
<AdminSidebarLayout>
|
||||||
<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 />
|
<AdminDashboard />
|
||||||
</div>
|
</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 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 : []
|
const logicRules = Array.isArray(parsed.logicRules) ? parsed.logicRules : []
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: { complexity, logicRules } })
|
return NextResponse.json({ success: true, data: { complexity, logicRules } })
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { BACKEND_URL } from "@/config/backend";
|
||||||
|
|
||||||
type VerifyState = "idle" | "loading" | "success" | "error";
|
type VerifyState = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
@ -9,14 +10,11 @@ interface VerificationResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
data: { message: string; user: { email: string; username: string } };
|
data: { message: string; user: { email: string; username: string } };
|
||||||
message: string;
|
message: string;
|
||||||
|
redirect?: string;
|
||||||
}
|
}
|
||||||
interface ErrorResponse {
|
// Removed unused ErrorResponse interface
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_BASE_URL = "http://localhost:8011";
|
|
||||||
|
|
||||||
const EmailVerification: React.FC = () => {
|
const EmailVerification: React.FC = () => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@ -51,30 +49,37 @@ const EmailVerification: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/auth/verify-email?token=${verificationToken}`,
|
`${BACKEND_URL}/api/auth/verify-email?token=${verificationToken}&format=json`,
|
||||||
{ method: "GET", headers: { "Content-Type": "application/json" }, signal: ctrl.signal }
|
{ method: "GET", headers: { "Accept": "application/json" }, signal: ctrl.signal }
|
||||||
);
|
);
|
||||||
|
|
||||||
const txt = await res.text();
|
const txt = await res.text();
|
||||||
let data: any = {};
|
console.log("Raw response text:", txt);
|
||||||
try { data = JSON.parse(txt || "{}"); } catch { /* ignore non-JSON */ }
|
console.log("Response status:", res.status);
|
||||||
|
console.log("Response headers:", Object.fromEntries(res.headers.entries()));
|
||||||
|
|
||||||
if (res.ok && (data as VerificationResponse)?.success) {
|
let data: Record<string, unknown> = {};
|
||||||
router.replace("/auth?verified=1");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg = String(data?.message || "").toLowerCase();
|
const msg = String(data?.message || "").toLowerCase();
|
||||||
if (msg.includes("already verified")) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setError(data?.message || `Verification failed (HTTP ${res.status}).`);
|
setError(String(data?.message) || `Verification failed (HTTP ${res.status}).`);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setStatus("error");
|
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);
|
console.error("Email verification error:", e);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@ -94,7 +99,7 @@ const EmailVerification: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/resend-verification`, {
|
const res = await fetch(`${BACKEND_URL}/api/auth/resend-verification`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { Metadata } from "next"
|
|||||||
import { Poppins } from "next/font/google"
|
import { Poppins } from "next/font/google"
|
||||||
import { AuthProvider } from "@/contexts/auth-context"
|
import { AuthProvider } from "@/contexts/auth-context"
|
||||||
import { AppLayout } from "@/components/layout/app-layout"
|
import { AppLayout } from "@/components/layout/app-layout"
|
||||||
|
import { ToastProvider } from "@/components/ui/toast"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
|
||||||
const poppins = Poppins({
|
const poppins = Poppins({
|
||||||
@ -33,11 +34,13 @@ html {
|
|||||||
`}</style>
|
`}</style>
|
||||||
</head>
|
</head>
|
||||||
<body className="font-sans antialiased dark bg-black text-white">
|
<body className="font-sans antialiased dark bg-black text-white">
|
||||||
|
<ToastProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ToastProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
|
import { Suspense } from "react"
|
||||||
import { SignInPage } from "@/components/auth/signin-page"
|
import { SignInPage } from "@/components/auth/signin-page"
|
||||||
|
|
||||||
export default function SignInPageRoute() {
|
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";
|
import EmailVerification from "@/app/auth/emailVerification";
|
||||||
|
|
||||||
export default function VerifyEmailPage() {
|
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 { Badge } from '@/components/ui/badge'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
|
||||||
XCircle,
|
XCircle,
|
||||||
Copy,
|
Clock,
|
||||||
Bell,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Copy,
|
||||||
|
Filter,
|
||||||
Search,
|
Search,
|
||||||
Filter
|
Edit,
|
||||||
|
Zap,
|
||||||
|
Files,
|
||||||
|
Bell,
|
||||||
|
BarChart3
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin'
|
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 { FeatureReviewDialog } from './feature-review-dialog'
|
||||||
import { AdminNotificationsPanel } from './admin-notifications-panel'
|
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 { Input } from '@/components/ui/input'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
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 [pendingFeatures, setPendingFeatures] = useState<AdminFeature[]>([])
|
||||||
const [notifications, setNotifications] = useState<AdminNotification[]>([])
|
const [customTemplates, setCustomTemplates] = useState<AdminTemplate[]>([])
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||||
|
const [templateStats, setTemplateStats] = useState<AdminStats | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [selectedFeature, setSelectedFeature] = useState<AdminFeature | null>(null)
|
const [selectedFeature, setSelectedFeature] = useState<AdminFeature | null>(null)
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<AdminTemplate | null>(null)
|
||||||
const [showReviewDialog, setShowReviewDialog] = useState(false)
|
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 [showNotifications, setShowNotifications] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
const [statusFilter, setStatusFilter] = useState<string>(filterParam)
|
||||||
|
|
||||||
|
const { unreadCount, removeByReference } = useAdminNotifications()
|
||||||
|
|
||||||
// Load dashboard data
|
// Load dashboard data
|
||||||
const loadDashboardData = async () => {
|
const loadDashboardData = async () => {
|
||||||
@ -41,15 +62,17 @@ export function AdminDashboard() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
const [pendingData, notificationsData, statsData] = await Promise.all([
|
const [featuresResponse, templatesResponse, featureStatsResponse, templateStatsResponse] = await Promise.all([
|
||||||
adminApi.getPendingFeatures(),
|
adminApi.getCustomFeatures('pending'),
|
||||||
adminApi.getNotifications(true, 10), // Get 10 unread notifications
|
adminApi.getCustomTemplates('pending'),
|
||||||
adminApi.getFeatureStats()
|
adminApi.getCustomFeatureStats(),
|
||||||
|
adminApi.getTemplateStats()
|
||||||
])
|
])
|
||||||
|
|
||||||
setPendingFeatures(pendingData)
|
setPendingFeatures(featuresResponse || [])
|
||||||
setNotifications(notificationsData)
|
setCustomTemplates(templatesResponse || [])
|
||||||
setStats(statsData)
|
setStats(featureStatsResponse)
|
||||||
|
setTemplateStats(templateStatsResponse)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load dashboard data')
|
setError(err instanceof Error ? err.message : 'Failed to load dashboard data')
|
||||||
console.error('Error loading dashboard data:', err)
|
console.error('Error loading dashboard data:', err)
|
||||||
@ -63,12 +86,14 @@ export function AdminDashboard() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Handle feature review
|
// 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 {
|
try {
|
||||||
await adminApi.reviewFeature(featureId, reviewData)
|
await adminApi.reviewFeature(featureId, reviewData)
|
||||||
|
|
||||||
// Remove the reviewed feature from pending list
|
// Update the feature in the list
|
||||||
setPendingFeatures(prev => prev.filter(f => f.id !== featureId))
|
setPendingFeatures(prev => prev.map(f =>
|
||||||
|
f.id === featureId ? { ...f, status: reviewData.status as AdminFeature['status'], admin_notes: reviewData.admin_notes } : f
|
||||||
|
))
|
||||||
|
|
||||||
// Reload stats
|
// Reload stats
|
||||||
const newStats = await adminApi.getFeatureStats()
|
const newStats = await adminApi.getFeatureStats()
|
||||||
@ -79,6 +104,141 @@ export function AdminDashboard() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error reviewing feature:', err)
|
console.error('Error reviewing feature:', err)
|
||||||
// Handle error (show toast notification, etc.)
|
// 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
|
return matchesSearch && matchesStatus
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get status counts
|
// Get status counts for features
|
||||||
const getStatusCount = (status: string) => {
|
const getFeatureStatusCount = (status: string) => {
|
||||||
return stats?.features.find(s => s.status === status)?.count || 0
|
return stats?.features?.find(s => s.status === status)?.count || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUnreadNotificationCount = () => {
|
// Get status counts for templates
|
||||||
return stats?.notifications.unread || 0
|
const getTemplateStatusCount = (status: string) => {
|
||||||
|
return (templateStats as AdminStats & { templates?: Array<{ status: string; count: number }> })?.templates?.find((s) => s.status === status)?.count || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<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">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
|
<h1 className="text-3xl font-bold text-white">Admin Dashboard</h1>
|
||||||
<p className="text-gray-600">Manage feature approvals and system notifications</p>
|
<p className="text-white/70">Manage feature approvals and system notifications</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button onClick={loadDashboardData} className="bg-orange-500 text-black hover:bg-orange-600">
|
||||||
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}>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
@ -163,70 +312,166 @@ export function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<Card>
|
{/* Features Stats */}
|
||||||
<CardContent className="pt-6">
|
<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">
|
<div className="flex items-center space-x-2">
|
||||||
<Clock className="h-5 w-5 text-yellow-600" />
|
<Clock className="h-4 w-4 text-yellow-600" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Pending</p>
|
<p className="text-xs text-white/60">Pending</p>
|
||||||
<p className="text-2xl font-bold">{getStatusCount('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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
{/* Templates Stats */}
|
||||||
<CardContent className="pt-6">
|
<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">
|
<div className="flex items-center space-x-2">
|
||||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
<Clock className="h-4 w-4 text-yellow-600" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Approved</p>
|
<p className="text-xs text-white/60">Pending</p>
|
||||||
<p className="text-2xl font-bold">{getStatusCount('approved')}</p>
|
<p className="text-xl font-bold text-white">{getTemplateStatusCount('pending')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<XCircle className="h-5 w-5 text-red-600" />
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Rejected</p>
|
<p className="text-xs text-white/60">Approved</p>
|
||||||
<p className="text-2xl font-bold">{getStatusCount('rejected')}</p>
|
<p className="text-xl font-bold text-white">{getTemplateStatusCount('approved')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Copy className="h-5 w-5 text-orange-600" />
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Duplicates</p>
|
<p className="text-xs text-white/60">Rejected</p>
|
||||||
<p className="text-2xl font-bold">{getStatusCount('duplicate')}</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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Quick Actions */}
|
||||||
<Tabs defaultValue="pending" className="space-y-4">
|
<Card className="bg-gray-900 border-gray-800">
|
||||||
<TabsList>
|
<CardHeader>
|
||||||
<TabsTrigger value="pending" className="flex items-center space-x-2">
|
<CardTitle className="text-lg font-semibold text-white">Quick Actions</CardTitle>
|
||||||
<Clock className="h-4 w-4" />
|
</CardHeader>
|
||||||
<span>Pending Review ({pendingFeatures.length})</span>
|
<CardContent>
|
||||||
</TabsTrigger>
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<TabsTrigger value="all" className="flex items-center space-x-2">
|
<Button
|
||||||
<Filter className="h-4 w-4" />
|
variant="outline"
|
||||||
<span>All Features</span>
|
className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
|
||||||
</TabsTrigger>
|
onClick={() => window.location.href = '/admin?tab=features'}
|
||||||
</TabsList>
|
>
|
||||||
|
<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 */}
|
{/* Filters */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -304,15 +549,38 @@ export function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedFeature(feature)
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -321,40 +589,38 @@ export function AdminDashboard() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="all" className="space-y-4">
|
{/* Feature Edit Dialog */}
|
||||||
<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 */}
|
|
||||||
{selectedFeature && (
|
{selectedFeature && (
|
||||||
<FeatureReviewDialog
|
<FeatureEditDialog
|
||||||
feature={selectedFeature}
|
feature={selectedFeature}
|
||||||
open={showReviewDialog}
|
open={showFeatureEditDialog}
|
||||||
onOpenChange={setShowReviewDialog}
|
onOpenChange={setShowFeatureEditDialog}
|
||||||
onReview={handleFeatureReview}
|
onUpdate={handleFeatureUpdate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notifications Panel */}
|
{/* Reject Dialog */}
|
||||||
<AdminNotificationsPanel
|
{rejectItem && (
|
||||||
open={showNotifications}
|
<RejectDialog
|
||||||
onOpenChange={setShowNotifications}
|
open={showRejectDialog}
|
||||||
notifications={notifications}
|
onOpenChange={setShowRejectDialog}
|
||||||
onNotificationRead={async (id) => {
|
onReject={handleReject}
|
||||||
try {
|
title={`Reject ${rejectItem.type === 'feature' ? 'Feature' : 'Template'}`}
|
||||||
await adminApi.markNotificationAsRead(id)
|
itemName={rejectItem.name}
|
||||||
setNotifications(prev => prev.filter(n => n.id !== id))
|
itemType={rejectItem.type}
|
||||||
} catch (err) {
|
|
||||||
console.error('Error marking notification as read:', err)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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 {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
|
||||||
Copy,
|
|
||||||
Clock,
|
Clock,
|
||||||
Check,
|
Check,
|
||||||
Trash2
|
Trash2,
|
||||||
|
Wifi,
|
||||||
|
WifiOff
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { AdminNotification } from '@/types/admin.types'
|
|
||||||
import { formatDate } from '@/lib/api/admin'
|
import { formatDate } from '@/lib/api/admin'
|
||||||
|
import { useAdminNotifications } from '@/contexts/AdminNotificationContext'
|
||||||
|
|
||||||
interface AdminNotificationsPanelProps {
|
interface AdminNotificationsPanelProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
notifications: AdminNotification[]
|
|
||||||
onNotificationRead: (id: string) => Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminNotificationsPanel({
|
export function AdminNotificationsPanel({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange
|
||||||
notifications,
|
|
||||||
onNotificationRead
|
|
||||||
}: AdminNotificationsPanelProps) {
|
}: AdminNotificationsPanelProps) {
|
||||||
const [markingAsRead, setMarkingAsRead] = useState<string | null>(null)
|
const [markingAsRead, setMarkingAsRead] = useState<string | null>(null)
|
||||||
|
const {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
isConnected,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
clearAll
|
||||||
|
} = useAdminNotifications()
|
||||||
|
|
||||||
const handleMarkAsRead = async (id: string) => {
|
const handleMarkAsRead = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
setMarkingAsRead(id)
|
setMarkingAsRead(id)
|
||||||
await onNotificationRead(id)
|
await markAsRead(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error marking notification as read:', error)
|
console.error('Error marking notification as read:', error)
|
||||||
} finally {
|
} 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) => {
|
const getNotificationIcon = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'new_feature':
|
case 'new_feature':
|
||||||
@ -81,14 +101,27 @@ export function AdminNotificationsPanel({
|
|||||||
<SheetTitle className="flex items-center space-x-2">
|
<SheetTitle className="flex items-center space-x-2">
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
<span>Admin Notifications</span>
|
<span>Admin Notifications</span>
|
||||||
{unreadNotifications.length > 0 && (
|
{unreadCount > 0 && (
|
||||||
<Badge className="bg-red-500 text-white">
|
<Badge className="bg-red-500 text-white">
|
||||||
{unreadNotifications.length}
|
{unreadCount}
|
||||||
</Badge>
|
</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>
|
</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
System notifications and feature review updates
|
System notifications and feature review updates • Real-time updates {isConnected ? 'enabled' : 'disabled'}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@ -187,7 +220,7 @@ export function AdminNotificationsPanel({
|
|||||||
<Bell className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
<Bell className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||||
<p className="text-gray-500">No notifications</p>
|
<p className="text-gray-500">No notifications</p>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -203,14 +236,20 @@ export function AdminNotificationsPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={handleMarkAllAsRead}
|
||||||
// TODO: Implement mark all as read
|
|
||||||
console.log('Mark all as read')
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4 mr-2" />
|
<Check className="h-4 w-4 mr-2" />
|
||||||
Mark All Read
|
Mark All Read
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="ml-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
import { analyzeFeatureWithAI } from '@/services/aiAnalysis'
|
import { analyzeFeatureWithAI } from '@/services/aiAnalysis'
|
||||||
|
|
||||||
type Complexity = 'low' | 'medium' | 'high'
|
type Complexity = 'low' | 'medium' | 'high'
|
||||||
@ -26,11 +33,12 @@ export function AICustomFeatureCreator({
|
|||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
projectType?: string
|
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
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const [featureName, setFeatureName] = useState('')
|
const [featureName, setFeatureName] = useState('')
|
||||||
const [featureDescription, setFeatureDescription] = useState('')
|
const [featureDescription, setFeatureDescription] = useState('')
|
||||||
|
const [selectedComplexity, setSelectedComplexity] = useState<Complexity | undefined>(undefined)
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||||
const [aiAnalysis, setAiAnalysis] = useState<AIAnalysisResult | null>(null)
|
const [aiAnalysis, setAiAnalysis] = useState<AIAnalysisResult | null>(null)
|
||||||
const [analysisError, setAnalysisError] = useState<string | null>(null)
|
const [analysisError, setAnalysisError] = useState<string | null>(null)
|
||||||
@ -55,10 +63,10 @@ export function AICustomFeatureCreator({
|
|||||||
|
|
||||||
setAiAnalysis({
|
setAiAnalysis({
|
||||||
suggested_name: featureName,
|
suggested_name: featureName,
|
||||||
complexity: overall.complexity,
|
complexity: overall.complexity, // Using the complexity from the API response
|
||||||
implementation_details: [],
|
implementation_details: [],
|
||||||
technical_requirements: [],
|
technical_requirements: [],
|
||||||
estimated_effort: 'Medium',
|
estimated_effort: overall.complexity === 'high' ? 'High' : overall.complexity === 'low' ? 'Low' : 'Medium',
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
api_endpoints: [],
|
api_endpoints: [],
|
||||||
database_tables: [],
|
database_tables: [],
|
||||||
@ -83,7 +91,8 @@ export function AICustomFeatureCreator({
|
|||||||
onAdd({
|
onAdd({
|
||||||
name: aiAnalysis.suggested_name || featureName.trim() || 'Custom Feature',
|
name: aiAnalysis.suggested_name || featureName.trim() || 'Custom Feature',
|
||||||
description: featureDescription.trim(),
|
description: featureDescription.trim(),
|
||||||
complexity: aiAnalysis.complexity || 'medium',
|
complexity: selectedComplexity || aiAnalysis.complexity || 'medium',
|
||||||
|
logic_rules: logicRules,
|
||||||
})
|
})
|
||||||
onClose()
|
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>
|
<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>
|
||||||
|
|
||||||
|
<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 */}
|
{/* Dynamic Requirements List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-white font-medium">Detailed Requirements (Add one by one)</div>
|
<div className="text-white font-medium">Detailed Requirements (Add one by one)</div>
|
||||||
@ -218,20 +245,20 @@ export function AICustomFeatureCreator({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
|
||||||
</div>
|
{/* Form Actions */}
|
||||||
<div className="p-6 border-t border-white/10">
|
<div className="flex gap-3 flex-wrap items-center pt-4 border-t border-white/10">
|
||||||
<div className="flex gap-3 flex-wrap items-center">
|
|
||||||
{aiAnalysis && (
|
{aiAnalysis && (
|
||||||
<div className="flex-1 text-white/80 text-sm">
|
<div className="flex-1 text-white/80 text-sm">
|
||||||
Overall Complexity: <span className="capitalize">{aiAnalysis.complexity}</span>
|
Overall Complexity: <span className="capitalize">{aiAnalysis.complexity}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button type="button" variant="outline" onClick={onClose} className="border-white/20 text-white hover:bg-white/10">Cancel</Button>
|
<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">
|
<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'}
|
{aiAnalysis ? 'Add Feature with Tagged Rules' : 'Analyze & Add Feature'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { safeLocalStorage, safeRedirect } from "@/lib/utils";
|
import { safeLocalStorage } from "@/lib/utils";
|
||||||
|
import { BACKEND_URL } from "@/config/backend";
|
||||||
const API_BASE_URL = "http://localhost:8011";
|
|
||||||
|
|
||||||
let accessToken = safeLocalStorage.getItem('accessToken');
|
let accessToken = safeLocalStorage.getItem('accessToken');
|
||||||
let refreshToken = safeLocalStorage.getItem('refreshToken');
|
let refreshToken = safeLocalStorage.getItem('refreshToken');
|
||||||
@ -20,8 +19,43 @@ export const clearTokens = () => {
|
|||||||
safeLocalStorage.removeItem('refreshToken');
|
safeLocalStorage.removeItem('refreshToken');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAccessToken = () => accessToken;
|
export const getAccessToken = () => {
|
||||||
export const getRefreshToken = () => refreshToken;
|
// 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
|
// Logout function that calls the backend API
|
||||||
export const logout = async () => {
|
export const logout = async () => {
|
||||||
@ -42,7 +76,7 @@ export const logout = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const authApiClient = axios.create({
|
export const authApiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: BACKEND_URL,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,16 @@
|
|||||||
import { authApiClient } from "./authApiClients";
|
import { authApiClient } from "./authApiClients";
|
||||||
import { safeLocalStorage } from "@/lib/utils";
|
import { safeLocalStorage } from "@/lib/utils";
|
||||||
|
import { BACKEND_URL } from "@/config/backend";
|
||||||
|
|
||||||
|
interface ApiResponse {
|
||||||
|
data?: {
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface ApiError extends Error {
|
interface ApiError extends Error {
|
||||||
response?: any;
|
response?: ApiResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const registerUser = async (
|
export const registerUser = async (
|
||||||
@ -17,19 +25,37 @@ export const registerUser = async (
|
|||||||
) => {
|
) => {
|
||||||
console.log("Registering user with data:", data);
|
console.log("Registering user with data:", data);
|
||||||
try {
|
try {
|
||||||
const response = await authApiClient.post(
|
// Using centralized backend URL
|
||||||
"/api/auth/register",
|
const response = await fetch(`${BACKEND_URL}/api/auth/register`, {
|
||||||
data
|
method: 'POST',
|
||||||
);
|
headers: {
|
||||||
return response.data;
|
'Content-Type': 'application/json',
|
||||||
} catch (error: any) {
|
},
|
||||||
console.error("Error registering user:", error.response?.data || error.message);
|
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(
|
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;
|
throw enhancedError;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -37,19 +63,37 @@ export const registerUser = async (
|
|||||||
export const loginUser = async (email: string, password: string) => {
|
export const loginUser = async (email: string, password: string) => {
|
||||||
console.log("Logging in user with email:", email);
|
console.log("Logging in user with email:", email);
|
||||||
try {
|
try {
|
||||||
const response = await authApiClient.post(
|
// Using centralized backend URL
|
||||||
"/api/auth/login",
|
const response = await fetch(`${BACKEND_URL}/api/auth/login`, {
|
||||||
{ email, password }
|
method: 'POST',
|
||||||
);
|
headers: {
|
||||||
return response.data;
|
'Content-Type': 'application/json',
|
||||||
} catch (error: any) {
|
},
|
||||||
console.error("Error logging in user:", error.response?.data || error.message);
|
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(
|
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;
|
throw enhancedError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -59,17 +103,28 @@ export const logoutUser = async () => {
|
|||||||
try {
|
try {
|
||||||
const refreshToken = safeLocalStorage.getItem('refreshToken');
|
const refreshToken = safeLocalStorage.getItem('refreshToken');
|
||||||
if (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" };
|
return { success: true, message: "Logged out successfully" };
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error("Error logging out user:", error.response?.data || error.message);
|
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(
|
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;
|
throw enhancedError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -12,7 +12,11 @@ import { useAuth } from "@/contexts/auth-context"
|
|||||||
import { loginUser } from "@/components/apis/authenticationHandler"
|
import { loginUser } from "@/components/apis/authenticationHandler"
|
||||||
import { setTokens } from "@/components/apis/authApiClients"
|
import { setTokens } from "@/components/apis/authApiClients"
|
||||||
|
|
||||||
export function SignInForm() {
|
interface SignInFormProps {
|
||||||
|
onToggleMode?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignInForm({ }: SignInFormProps) {
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
@ -53,16 +57,17 @@ export function SignInForm() {
|
|||||||
} else {
|
} else {
|
||||||
setError("Invalid response from server. Please try again.")
|
setError("Invalid response from server. Please try again.")
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error('Login error:', err)
|
console.error('Login error:', err)
|
||||||
|
|
||||||
// Handle different types of errors
|
// Handle different types of errors
|
||||||
if (err.response?.data?.message) {
|
const error = err as { response?: { data?: { message?: string; error?: string } }; message?: string };
|
||||||
setError(err.response.data.message)
|
if (error.response?.data?.message) {
|
||||||
} else if (err.response?.data?.error) {
|
setError(error.response.data.message)
|
||||||
setError(err.response.data.error)
|
} else if (error.response?.data?.error) {
|
||||||
} else if (err.message) {
|
setError(error.response.data.error)
|
||||||
setError(err.message)
|
} else if (error.message) {
|
||||||
|
setError(error.message)
|
||||||
} else {
|
} else {
|
||||||
setError("An error occurred during login. Please try again.")
|
setError("An error occurred during login. Please try again.")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export function SignInPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for verification messages in URL
|
// Check for verification messages in URL
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
const verified = searchParams.get('verified')
|
const verified = searchParams.get('verified')
|
||||||
const message = searchParams.get('message')
|
const message = searchParams.get('message')
|
||||||
const error = searchParams.get('error')
|
const error = searchParams.get('error')
|
||||||
@ -36,6 +37,7 @@ export function SignInPage() {
|
|||||||
}, 5000)
|
}, 5000)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -106,7 +108,7 @@ export function SignInPage() {
|
|||||||
<SignInForm />
|
<SignInForm />
|
||||||
|
|
||||||
<div className="text-center pt-4">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="link"
|
variant="link"
|
||||||
|
|||||||
@ -7,13 +7,13 @@ import { useRouter } from "next/navigation"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
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 { Eye, EyeOff, Loader2, User, Mail, Lock, Shield } from "lucide-react"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
|
||||||
import { registerUser } from "../apis/authenticationHandler"
|
import { registerUser } from "../apis/authenticationHandler"
|
||||||
|
|
||||||
interface SignUpFormProps {
|
interface SignUpFormProps {
|
||||||
onSignUpSuccess?: () => void
|
onSignUpSuccess?: () => void
|
||||||
|
onToggleMode?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
|
export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
|
||||||
@ -31,6 +31,7 @@ export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
|
|||||||
role: "user" // default role, adjust as needed
|
role: "user" // default role, adjust as needed
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// const { signup } = useAuth() // Unused variable
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@ -70,18 +71,18 @@ export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
|
|||||||
onSignUpSuccess()
|
onSignUpSuccess()
|
||||||
} else {
|
} else {
|
||||||
// Default behavior - redirect to signin with message
|
// Default behavior - redirect to signin with message
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
router.push("/signin?message=Account created successfully! Please check your email to verify your account.")
|
router.push("/signin?message=Account created successfully! Please check your email to verify your account.")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(response.message || "Failed to create account. Please try again.")
|
setError(response.message || "Failed to create account. Please try again.")
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error('Signup error:', err)
|
console.error('Signup error:', err)
|
||||||
|
|
||||||
// Handle different types of errors
|
// The authentication handler now properly extracts error messages from server responses
|
||||||
if (err.response?.data?.message) {
|
if (err instanceof Error) {
|
||||||
setError(err.response.data.message)
|
|
||||||
} else if (err.message) {
|
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} else {
|
} else {
|
||||||
setError("An error occurred during registration. Please try again.")
|
setError("An error occurred during registration. Please try again.")
|
||||||
|
|||||||
@ -14,8 +14,10 @@ export function SignUpPage() {
|
|||||||
setIsSuccess(true)
|
setIsSuccess(true)
|
||||||
// Redirect to signin after 3 seconds
|
// Redirect to signin after 3 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
router.push("/signin?message=Please check your email to verify your account")
|
router.push("/signin?message=Please check your email to verify your account")
|
||||||
}, 3000)
|
}
|
||||||
|
}, 3001)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
@ -68,7 +70,7 @@ export function SignUpPage() {
|
|||||||
<CheckCircle className="h-10 w-10 text-white" />
|
<CheckCircle className="h-10 w-10 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-white mb-2">Account Created Successfully!</h1>
|
<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">
|
<div className="bg-orange-500/10 border border-orange-500/30 rounded-lg p-4 mb-6">
|
||||||
<p className="text-orange-300 text-sm">
|
<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.
|
<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"
|
"use client"
|
||||||
|
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
|
||||||
import { Header } from "@/components/navigation/header"
|
import { Header } from "@/components/navigation/header"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
|
|
||||||
@ -9,14 +8,15 @@ interface AppLayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppLayout({ children }: AppLayoutProps) {
|
export function AppLayout({ children }: AppLayoutProps) {
|
||||||
const { user } = useAuth()
|
// const { user } = useAuth()
|
||||||
const pathname = usePathname()
|
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 isAuthPage = pathname === "/signin" || pathname === "/signup"
|
||||||
|
const isAdminPage = pathname?.startsWith("/admin")
|
||||||
|
|
||||||
// For auth pages, don't show header
|
// For auth pages and admin pages, don't show header
|
||||||
if (isAuthPage) {
|
if (isAuthPage || isAdminPage) {
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,13 +7,16 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { ArrowRight, Plus, Globe, BarChart3, Zap, Code, Search, Star, Clock, Users, Layers, AlertCircle, Edit, Trash2, Palette } from "lucide-react"
|
import { EditFeatureForm } from "@/components/edit-feature-form"
|
||||||
|
import { ArrowRight, Plus, Globe, BarChart3, Zap, Code, Search, Star, Clock, Users, Layers, AlertCircle, Edit, Trash2, User, Palette } from "lucide-react"
|
||||||
import { useTemplates } from "@/hooks/useTemplates"
|
import { useTemplates } from "@/hooks/useTemplates"
|
||||||
import { CustomTemplateForm } from "@/components/custom-template-form"
|
import { CustomTemplateForm } from "@/components/custom-template-form"
|
||||||
import { EditTemplateForm } from "@/components/edit-template-form"
|
import { EditTemplateForm } from "@/components/edit-template-form"
|
||||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||||
import { DatabaseTemplate, TemplateFeature } from "@/lib/template-service"
|
import { DatabaseTemplate, TemplateFeature } from "@/lib/template-service"
|
||||||
import AICustomFeatureCreator from "@/components/ai/AICustomFeatureCreator"
|
import AICustomFeatureCreator from "@/components/ai/AICustomFeatureCreator"
|
||||||
|
import { BACKEND_URL } from "@/config/backend"
|
||||||
|
import { Tooltip } from "@/components/ui/tooltip"
|
||||||
import WireframeCanvas from "@/components/wireframe-canvas"
|
import WireframeCanvas from "@/components/wireframe-canvas"
|
||||||
import PromptSidePanel from "@/components/prompt-side-panel"
|
import PromptSidePanel from "@/components/prompt-side-panel"
|
||||||
|
|
||||||
@ -37,6 +40,8 @@ interface Template {
|
|||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
|
featureCount?: number
|
||||||
|
is_custom?: boolean; // Add this field to identify custom templates
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => void }) {
|
function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => void }) {
|
||||||
@ -46,83 +51,158 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
const [editingTemplate, setEditingTemplate] = useState<DatabaseTemplate | null>(null)
|
const [editingTemplate, setEditingTemplate] = useState<DatabaseTemplate | null>(null)
|
||||||
const [deletingTemplate, setDeletingTemplate] = useState<DatabaseTemplate | null>(null)
|
const [deletingTemplate, setDeletingTemplate] = useState<DatabaseTemplate | null>(null)
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||||
const [databaseTemplates, setDatabaseTemplates] = useState<Template[]>([])
|
// Keep a stable list of all categories seen so the filter chips don't disappear
|
||||||
const [templatesLoading, setTemplatesLoading] = useState(true)
|
const [knownCategories, setKnownCategories] = useState<Set<string>>(new Set(["all"]))
|
||||||
|
// Cache counts per category using the API totals for each filtered fetch
|
||||||
|
const [categoryCounts, setCategoryCounts] = useState<Record<string, number>>({ all: 0 })
|
||||||
|
|
||||||
const { templates: dbTemplates, loading, error, getTemplatesForUI, createTemplate, updateTemplate, deleteTemplate } = useTemplates()
|
const {
|
||||||
|
user,
|
||||||
|
combined,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
paginationState,
|
||||||
|
createTemplate,
|
||||||
|
updateTemplate,
|
||||||
|
deleteTemplate,
|
||||||
|
fetchTemplatesWithPagination,
|
||||||
|
loadMoreTemplates,
|
||||||
|
} = useTemplates()
|
||||||
|
|
||||||
// Load templates with features when dbTemplates changes
|
// Initial fetch is handled inside useTemplates hook; avoid duplicate fetch here
|
||||||
|
|
||||||
|
// Handle category changes immediately (no debounce) so switching chips is snappy
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadTemplates = async () => {
|
if (selectedCategory !== paginationState.selectedCategory) {
|
||||||
if (Object.keys(dbTemplates).length > 0) {
|
console.log('[TemplateSelectionStep] Triggering fetch due to category change:', { selectedCategory });
|
||||||
setTemplatesLoading(true)
|
fetchTemplatesWithPagination({
|
||||||
try {
|
page: 0,
|
||||||
const templatesWithFeatures = await getTemplatesForUI()
|
pageSize: paginationState.pageSize,
|
||||||
setDatabaseTemplates(templatesWithFeatures)
|
category: selectedCategory,
|
||||||
} catch (error) {
|
search: searchQuery,
|
||||||
console.error('Error loading templates with features:', error)
|
resetPagination: true,
|
||||||
setDatabaseTemplates([])
|
});
|
||||||
} finally {
|
|
||||||
setTemplatesLoading(false)
|
|
||||||
}
|
}
|
||||||
|
}, [selectedCategory, paginationState.selectedCategory, paginationState.pageSize, searchQuery, fetchTemplatesWithPagination]);
|
||||||
|
|
||||||
|
// Handle search changes with debouncing
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (searchQuery !== paginationState.searchQuery) {
|
||||||
|
console.log('[TemplateSelectionStep] Triggering fetch due to search change:', { searchQuery });
|
||||||
|
fetchTemplatesWithPagination({
|
||||||
|
page: 0,
|
||||||
|
pageSize: paginationState.pageSize,
|
||||||
|
category: selectedCategory,
|
||||||
|
search: searchQuery,
|
||||||
|
resetPagination: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}, 500);
|
||||||
loadTemplates()
|
return () => clearTimeout(timeoutId);
|
||||||
}, [dbTemplates])
|
}, [searchQuery, selectedCategory, paginationState.searchQuery, paginationState.pageSize, fetchTemplatesWithPagination]);
|
||||||
|
|
||||||
// Fallback static templates if database is empty or loading
|
// Track categories seen across any fetch so the chips remain visible
|
||||||
const fallbackTemplates: Template[] = [
|
useEffect(() => {
|
||||||
{ id: "marketing-website", title: "Marketing Website", description: "Professional marketing site with CMS and lead generation", category: "Marketing", features: ["Content Management", "Contact Forms", "SEO Optimization", "Analytics Integration"], complexity: 2, timeEstimate: "1-2 weeks", techStack: ["Next.js", "Sanity CMS", "Tailwind CSS", "Vercel"], popularity: 95, lastUpdated: "2024-01-15" },
|
if (!combined?.data?.length) return;
|
||||||
{ id: "saas-platform", title: "SaaS Platform", description: "Complete SaaS application with user management, billing, and analytics", category: "SaaS Platform", features: ["User Authentication", "Payment Processing", "Analytics Integration", "API Management"], complexity: 5, timeEstimate: "4-6 weeks", techStack: ["Next.js", "PostgreSQL", "Stripe", "NextAuth.js"], popularity: 92, lastUpdated: "2024-01-15" },
|
setKnownCategories((prev) => {
|
||||||
]
|
const next = new Set(prev);
|
||||||
|
combined.data.forEach((t) => {
|
||||||
|
if (t.category) next.add(t.category);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [combined?.data]);
|
||||||
|
|
||||||
// Use database templates if available, otherwise fallback
|
// Update counts cache based on API totals for the currently selected category
|
||||||
const templates: Template[] = databaseTemplates.length > 0 ? databaseTemplates : fallbackTemplates
|
useEffect(() => {
|
||||||
|
const currentCat = paginationState.selectedCategory || 'all';
|
||||||
|
const totalForFilter = paginationState.total || 0;
|
||||||
|
setCategoryCounts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[currentCat]: totalForFilter,
|
||||||
|
}));
|
||||||
|
}, [paginationState.selectedCategory, paginationState.total]);
|
||||||
|
|
||||||
|
const templates: Template[] = combined?.data?.length
|
||||||
|
? combined.data.map((t) => {
|
||||||
|
console.log('[TemplateSelectionStep] Processing template:', {
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
category: t.category,
|
||||||
|
feature_count: t.feature_count
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
description: t.description || "No description available",
|
||||||
|
category: t.category,
|
||||||
|
features: [],
|
||||||
|
complexity: 3, // Default complexity since DatabaseTemplate doesn't have this property
|
||||||
|
timeEstimate: "2-4 weeks",
|
||||||
|
techStack: ["Next.js", "PostgreSQL", "Tailwind CSS"],
|
||||||
|
popularity: t.avg_rating ? Math.round(t.avg_rating * 20) : 75,
|
||||||
|
lastUpdated: t.updated_at ? new Date(t.updated_at).toISOString().split('T')[0] : undefined,
|
||||||
|
type: t.type,
|
||||||
|
icon: t.icon,
|
||||||
|
gradient: t.gradient,
|
||||||
|
border: t.border,
|
||||||
|
text: t.text,
|
||||||
|
subtext: t.subtext,
|
||||||
|
is_active: t.is_active,
|
||||||
|
created_at: t.created_at,
|
||||||
|
updated_at: t.updated_at,
|
||||||
|
featureCount: t.feature_count || 0, // Add feature count from API
|
||||||
|
is_custom: t.is_custom, // Add is_custom field
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('[TemplateSelectionStep] Debug:', {
|
||||||
|
hasCombined: !!combined,
|
||||||
|
hasData: !!combined?.data,
|
||||||
|
dataType: typeof combined?.data,
|
||||||
|
isArray: Array.isArray(combined?.data),
|
||||||
|
dataLength: combined?.data?.length || 0,
|
||||||
|
templatesLength: templates.length,
|
||||||
|
paginationState,
|
||||||
|
templates: templates.map((t) => ({ id: t.id, title: t.title, category: t.category })),
|
||||||
|
});
|
||||||
|
|
||||||
// Generate dynamic categories from templates
|
|
||||||
const getCategories = () => {
|
const getCategories = () => {
|
||||||
const categoryMap = new Map<string, { name: string; icon: any; count: number }>()
|
const categoryMap = new Map<string, { name: string; icon: React.ComponentType<{ className?: string }>; count: number }>();
|
||||||
|
// All category: prefer live total when currently filtered by 'all',
|
||||||
|
// otherwise show the last cached overall total (if any)
|
||||||
|
const liveAllTotal = paginationState.selectedCategory === 'all' ? (paginationState.total || 0) : 0;
|
||||||
|
const cachedAllTotal = categoryCounts['all'] ?? 0;
|
||||||
|
categoryMap.set("all", { name: "All Templates", icon: Globe, count: Math.max(liveAllTotal, cachedAllTotal) });
|
||||||
|
|
||||||
// Add "All Templates" category
|
// Build chips from the union of all categories we have ever seen
|
||||||
categoryMap.set("all", { name: "All Templates", icon: Globe, count: templates.length })
|
Array.from(knownCategories)
|
||||||
|
.filter((c) => c !== 'all')
|
||||||
|
.forEach((categoryId) => {
|
||||||
|
const lower = categoryId.toLowerCase();
|
||||||
|
let icon = Code;
|
||||||
|
if (lower.includes('marketing') || lower.includes('branding')) icon = Zap;
|
||||||
|
else if (lower.includes('seo') || lower.includes('content')) icon = BarChart3;
|
||||||
|
else if (lower.includes('food') || lower.includes('delivery')) icon = Users;
|
||||||
|
|
||||||
// Add categories from templates
|
// Prefer cached count for this category (set when that filter is active). Fallback to visible page count.
|
||||||
templates.forEach(template => {
|
const fallbackCount = templates.filter((t) => t.category === categoryId).length;
|
||||||
if (!categoryMap.has(template.category)) {
|
const count = categoryCounts[categoryId] ?? fallbackCount;
|
||||||
// Choose icon based on category name
|
|
||||||
let icon = Code // default
|
|
||||||
if (template.category.toLowerCase().includes('marketing') || template.category.toLowerCase().includes('branding')) {
|
|
||||||
icon = Zap
|
|
||||||
} else if (template.category.toLowerCase().includes('seo') || template.category.toLowerCase().includes('content')) {
|
|
||||||
icon = BarChart3
|
|
||||||
} else if (template.category.toLowerCase().includes('food') || template.category.toLowerCase().includes('delivery')) {
|
|
||||||
icon = Users
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryMap.set(template.category, {
|
categoryMap.set(categoryId, {
|
||||||
name: template.category,
|
name: categoryId,
|
||||||
icon,
|
icon,
|
||||||
count: templates.filter(t => t.category === template.category).length
|
count,
|
||||||
})
|
});
|
||||||
}
|
});
|
||||||
})
|
|
||||||
|
|
||||||
return Array.from(categoryMap.entries()).map(([id, data]) => ({
|
return Array.from(categoryMap.entries()).map(([id, data]) => ({ id, ...data }));
|
||||||
id,
|
};
|
||||||
...data
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const categories = getCategories()
|
const categories = getCategories();
|
||||||
|
|
||||||
const filteredTemplates = templates.filter((template) => {
|
|
||||||
const matchesCategory = selectedCategory === "all" || template.category === selectedCategory
|
|
||||||
const matchesSearch =
|
|
||||||
template.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
template.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
template.features.some((feature) => feature.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
||||||
return matchesCategory && matchesSearch
|
|
||||||
})
|
|
||||||
|
|
||||||
const getComplexityColor = (complexity: number) => {
|
const getComplexityColor = (complexity: number) => {
|
||||||
if (complexity <= 2) return "bg-emerald-900/40 text-emerald-300 border border-emerald-800"
|
if (complexity <= 2) return "bg-emerald-900/40 text-emerald-300 border border-emerald-800"
|
||||||
@ -136,69 +216,149 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
return "Complex"
|
return "Complex"
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateTemplate = async (templateData: Partial<DatabaseTemplate>) => {
|
// Truncate helper to restrict displayed characters
|
||||||
|
const TITLE_MAX_CHARS = 15;
|
||||||
|
const DESC_MAX_CHARS = 120;
|
||||||
|
const truncate = (value: string | undefined | null, max: number) => {
|
||||||
|
const v = (value || '').trim();
|
||||||
|
if (v.length <= max) return v;
|
||||||
|
return v.slice(0, Math.max(0, max - 1)) + '…';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTemplate = async (templateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> => {
|
||||||
try {
|
try {
|
||||||
await createTemplate(templateData)
|
const created = await createTemplate({
|
||||||
setShowCustomForm(false)
|
...(templateData as any),
|
||||||
|
// Ensure backend routes to custom_templates table
|
||||||
|
is_custom: true,
|
||||||
|
source: 'custom',
|
||||||
|
// Attach user id so custom template is associated with creator
|
||||||
|
user_id: (user as any)?.id,
|
||||||
|
} as any);
|
||||||
|
setShowCustomForm(false);
|
||||||
|
return created;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating template:', error)
|
console.error('[TemplateSelectionStep] Error creating template:', error);
|
||||||
}
|
// Re-throw to satisfy the return type contract when errors should propagate
|
||||||
|
throw error as Error;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateTemplate = async (id: string, templateData: Partial<DatabaseTemplate>) => {
|
const handleUpdateTemplate = async (id: string, templateData: Partial<DatabaseTemplate>) => {
|
||||||
try {
|
try {
|
||||||
await updateTemplate(id, templateData)
|
// Find the template to determine if it's custom
|
||||||
setEditingTemplate(null)
|
const template = templates.find(t => t.id === id);
|
||||||
|
const isCustom = template?.is_custom || false;
|
||||||
|
|
||||||
|
await updateTemplate(id, templateData, isCustom);
|
||||||
|
setEditingTemplate(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating template:', error)
|
console.error('[TemplateSelectionStep] Error updating template:', error);
|
||||||
}
|
// Show user-friendly error message
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to update template';
|
||||||
|
alert(`Error updating template: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteTemplate = async () => {
|
const handleDeleteTemplate = async () => {
|
||||||
if (!deletingTemplate) return
|
if (!deletingTemplate) return;
|
||||||
|
setDeleteLoading(true);
|
||||||
setDeleteLoading(true)
|
|
||||||
try {
|
try {
|
||||||
await deleteTemplate(deletingTemplate.id)
|
// Find the template to determine if it's custom
|
||||||
setDeletingTemplate(null)
|
const template = templates.find(t => t.id === deletingTemplate.id);
|
||||||
} catch (error) {
|
const isCustom = template?.is_custom || false;
|
||||||
console.error('Error deleting template:', error)
|
|
||||||
} finally {
|
|
||||||
setDeleteLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state
|
await deleteTemplate(deletingTemplate.id, isCustom);
|
||||||
if (loading || templatesLoading) {
|
setDeletingTemplate(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TemplateSelectionStep] Error deleting template:', error);
|
||||||
|
// Show user-friendly error message
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to delete template';
|
||||||
|
alert(`Error deleting template: ${errorMessage}`);
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && templates.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
<h1 className="text-4xl font-bold text-white">Loading Templates...</h1>
|
<h1 className="text-4xl font-bold text-white">Loading Templates...</h1>
|
||||||
<p className="text-xl text-white/60">Fetching templates and features from database</p>
|
<p className="text-xl text-white/60">Fetching templates from /merged API</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-orange-500"></div>
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-orange-500"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show error state
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
// Check if this is an authentication error
|
||||||
|
const isAuthError = error.includes('Please sign in') || error.includes('Authentication required');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
|
{isAuthError ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-center space-x-2 text-orange-400">
|
||||||
|
<AlertCircle className="h-6 w-6" />
|
||||||
|
<h1 className="text-2xl font-bold">Sign In Required</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-white/60">{error}</p>
|
||||||
|
<div className="flex justify-center space-x-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/signin'}
|
||||||
|
className="bg-orange-500 hover:bg-orange-400 text-black"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
// Try to fetch public templates without user ID
|
||||||
|
fetchTemplatesWithPagination({
|
||||||
|
page: 0,
|
||||||
|
pageSize: paginationState.pageSize,
|
||||||
|
category: selectedCategory,
|
||||||
|
search: searchQuery,
|
||||||
|
resetPagination: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="border-white/20 text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Browse Public Templates
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex items-center justify-center space-x-2 text-red-400">
|
<div className="flex items-center justify-center space-x-2 text-red-400">
|
||||||
<AlertCircle className="h-6 w-6" />
|
<AlertCircle className="h-6 w-6" />
|
||||||
<h1 className="text-2xl font-bold">Error Loading Templates</h1>
|
<h1 className="text-2xl font-bold">Error Loading Templates</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/60">{error}</p>
|
<p className="text-white/60">{error}</p>
|
||||||
<Button onClick={() => window.location.reload()} className="bg-orange-500 hover:bg-orange-400 text-black">
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
fetchTemplatesWithPagination({
|
||||||
|
page: 0,
|
||||||
|
pageSize: paginationState.pageSize,
|
||||||
|
category: selectedCategory,
|
||||||
|
search: searchQuery,
|
||||||
|
resetPagination: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="bg-orange-500 hover:bg-orange-400 text-black"
|
||||||
|
>
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show custom template form
|
// Show custom template form
|
||||||
@ -210,7 +370,9 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
<p className="text-xl text-white/60">Design your own project template</p>
|
<p className="text-xl text-white/60">Design your own project template</p>
|
||||||
</div>
|
</div>
|
||||||
<CustomTemplateForm
|
<CustomTemplateForm
|
||||||
onSubmit={handleCreateTemplate}
|
onSubmit={async (templateData) => {
|
||||||
|
await handleCreateTemplate(templateData);
|
||||||
|
}}
|
||||||
onCancel={() => setShowCustomForm(false)}
|
onCancel={() => setShowCustomForm(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -265,9 +427,23 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
<p className="text-xl text-white/60 max-w-3xl mx-auto">
|
<p className="text-xl text-white/60 max-w-3xl mx-auto">
|
||||||
Select from our comprehensive library of professionally designed templates
|
Select from our comprehensive library of professionally designed templates
|
||||||
</p>
|
</p>
|
||||||
|
{!user?.id && (
|
||||||
|
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4 max-w-2xl mx-auto">
|
||||||
|
<p className="text-orange-300 text-sm">
|
||||||
|
You're currently viewing public templates.
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={() => window.location.href = '/signin'}
|
||||||
|
className="text-orange-400 hover:text-orange-300 p-0 h-auto font-semibold ml-1"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
{' '}to access your personal templates and create custom ones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="max-w-2xl mx-auto relative">
|
<div className="max-w-2xl mx-auto relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-white/40 h-5 w-5" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-white/40 h-5 w-5" />
|
||||||
@ -277,20 +453,23 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 h-12 text-lg border border-white/10 bg-white/5 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30 rounded-xl"
|
className="pl-10 h-12 text-lg border border-white/10 bg-white/5 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30 rounded-xl"
|
||||||
/>
|
/>
|
||||||
|
{paginationState.loading && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-orange-500"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category Filters */}
|
{(() => {
|
||||||
<div className="flex flex-wrap justify-center gap-3">
|
const renderChip = (category: { id: string; name: string; icon: React.ComponentType<{ className?: string }>; count: number }) => {
|
||||||
{categories.map((category) => {
|
const Icon = category.icon;
|
||||||
const Icon = category.icon
|
const active = selectedCategory === category.id;
|
||||||
const active = selectedCategory === category.id
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={category.id}
|
key={`cat-${category.id}`}
|
||||||
onClick={() => setSelectedCategory(category.id)}
|
onClick={() => setSelectedCategory(category.id)}
|
||||||
className={`flex items-center space-x-3 px-6 py-3 rounded-xl border transition-all cursor-pointer ${active
|
className={`shrink-0 whitespace-nowrap flex items-center space-x-3 px-6 py-3 rounded-xl border transition-all cursor-pointer ${
|
||||||
? "bg-orange-500 text-black border-orange-500"
|
active ? "bg-orange-500 text-black border-orange-500" : "bg-white/5 text-white/80 border-white/10 hover:bg-white/10"
|
||||||
: "bg-white/5 text-white/80 border-white/10 hover:bg-white/10"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`p-2 rounded-lg ${active ? "bg-white/10" : "bg-white/10"}`}>
|
<div className={`p-2 rounded-lg ${active ? "bg-white/10" : "bg-white/10"}`}>
|
||||||
@ -303,20 +482,65 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})}
|
};
|
||||||
|
|
||||||
|
const allChip = categories.find((c) => c.id === 'all');
|
||||||
|
const rest = categories.filter((c) => c.id !== 'all');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{allChip && (
|
||||||
|
<div className="shrink-0">
|
||||||
|
{renderChip(allChip)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative overflow-hidden flex-1 pb-2">
|
||||||
|
<div className="flex gap-3 whitespace-nowrap animate-scroll-x will-change-transform">
|
||||||
|
{rest.map(renderChip)}
|
||||||
|
{rest.map((c) => ({ ...c, id: `${c.id}-dup` })).map(renderChip)}
|
||||||
|
</div>
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes scroll-x { from { transform: translateX(0); } to { transform: translateX(-50%); } }
|
||||||
|
.animate-scroll-x { animation: scroll-x 20s linear infinite; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Templates Grid */}
|
{templates.length === 0 && !paginationState.loading ? (
|
||||||
|
<div className="text-center py-12 text-white/60">
|
||||||
|
<p>No templates found for the current filters.</p>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
fetchTemplatesWithPagination({
|
||||||
|
page: 0,
|
||||||
|
pageSize: paginationState.pageSize,
|
||||||
|
category: 'all',
|
||||||
|
search: '',
|
||||||
|
resetPagination: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-4 bg-orange-500 hover:bg-orange-400 text-black"
|
||||||
|
>
|
||||||
|
Reset Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{filteredTemplates.map((template) => (
|
{templates.map((template) => (
|
||||||
<Card key={template.id} className="group bg-white/5 border-white/10 hover:border-white/20 rounded-xl shadow-sm hover:shadow-md transition-all">
|
<Card key={template.id} className="group bg-white/5 border-white/10 hover:border-white/20 rounded-xl shadow-sm hover:shadow-md transition-all">
|
||||||
<div className="bg-white/5 px-4 py-4 border-b border-white/10">
|
<div className="bg-white/5 px-4 py-4 border-b border-white/10">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 flex-1 min-w-0">
|
||||||
<CardTitle className="text-xl font-bold text-white group-hover:text-orange-400 transition-colors line-clamp-2">
|
<CardTitle className="text-xl font-bold text-white group-hover:text-orange-400 transition-colors">
|
||||||
{template.title}
|
<Tooltip content={template.title}>
|
||||||
|
<div className="whitespace-nowrap overflow-hidden text-ellipsis max-w-full break-words hyphens-auto">
|
||||||
|
{truncate(template.title, TITLE_MAX_CHARS)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Badge className={`${getComplexityColor(template.complexity)} font-medium px-3 py-1 rounded-full`}>{getComplexityLabel(template.complexity)}</Badge>
|
<Badge className={`${getComplexityColor(template.complexity)} font-medium px-3 py-1 rounded-full`}>{getComplexityLabel(template.complexity)}</Badge>
|
||||||
@ -356,7 +580,11 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/80 text-sm leading-relaxed">{template.description}</p>
|
<Tooltip content={template.description}>
|
||||||
|
<p className="text-white/80 text-sm leading-relaxed line-clamp-2 break-words hyphens-auto overflow-hidden">
|
||||||
|
{truncate(template.description, DESC_MAX_CHARS)}
|
||||||
|
</p>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent className="p-4 flex flex-col h-full text-white/80">
|
<CardContent className="p-4 flex flex-col h-full text-white/80">
|
||||||
@ -368,7 +596,7 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Layers className="h-4 w-4 text-emerald-400" />
|
<Layers className="h-4 w-4 text-emerald-400" />
|
||||||
<span className="font-medium">{(template as any).featureCount ?? template.features.length} features</span>
|
<span className="font-medium">{template.featureCount || 0} features</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -406,33 +634,177 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Custom Template Option */}
|
<div className="flex justify-center pt-6">
|
||||||
<Card className="group border-dashed border-2 border-white/15 bg-white/5 hover:border-white/25 transition-all cursor-pointer" onClick={() => setShowCustomForm(true)}>
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (paginationState.currentPage > 0) {
|
||||||
|
fetchTemplatesWithPagination({
|
||||||
|
page: paginationState.currentPage - 1,
|
||||||
|
pageSize: paginationState.pageSize,
|
||||||
|
category: paginationState.selectedCategory,
|
||||||
|
search: paginationState.searchQuery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={paginationState.currentPage === 0 || paginationState.loading}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-white/20 text-white hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(paginationState.total / paginationState.pageSize));
|
||||||
|
const currentPage = paginationState.currentPage;
|
||||||
|
const pages = [];
|
||||||
|
|
||||||
|
// First page
|
||||||
|
pages.push(
|
||||||
|
<Button
|
||||||
|
key={1}
|
||||||
|
onClick={() => {
|
||||||
|
if (currentPage !== 0) {
|
||||||
|
fetchTemplatesWithPagination({
|
||||||
|
page: 0,
|
||||||
|
pageSize: paginationState.pageSize,
|
||||||
|
category: paginationState.selectedCategory,
|
||||||
|
search: paginationState.searchQuery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant={currentPage === 0 ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={currentPage === 0 ? "bg-orange-500 text-black border-orange-500 hover:bg-orange-400" : "border-white/20 text-white hover:bg-white/10"}
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ellipsis before current page
|
||||||
|
if (currentPage > 3) {
|
||||||
|
pages.push(<span key="ellipsis1" className="px-2 text-white/40">...</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages around current
|
||||||
|
for (let i = Math.max(1, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
|
||||||
|
if (i > 0 && i < totalPages) {
|
||||||
|
pages.push(
|
||||||
|
<Button
|
||||||
|
key={i + 1}
|
||||||
|
onClick={() => {
|
||||||
|
fetchTemplatesWithPagination({
|
||||||
|
page: i,
|
||||||
|
pageSize: paginationState.pageSize,
|
||||||
|
category: paginationState.selectedCategory,
|
||||||
|
search: paginationState.searchQuery,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant={currentPage === i ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={currentPage === i ? "bg-orange-500 text-black border-orange-500 hover:bg-orange-400" : "border-white/20 text-white hover:bg-white/10"}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ellipsis after current page
|
||||||
|
if (currentPage < totalPages - 3) {
|
||||||
|
pages.push(<span key="ellipsis2" className="px-2 text-white/40">...</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last page (only if not already rendered by the middle range)
|
||||||
|
if (totalPages > 1 && currentPage < totalPages - 2) {
|
||||||
|
pages.push(
|
||||||
|
<Button
|
||||||
|
key={totalPages}
|
||||||
|
onClick={() => {
|
||||||
|
fetchTemplatesWithPagination({
|
||||||
|
page: totalPages - 1,
|
||||||
|
pageSize: paginationState.pageSize,
|
||||||
|
category: paginationState.selectedCategory,
|
||||||
|
search: paginationState.searchQuery,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant={currentPage === totalPages - 1 ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={currentPage === totalPages - 1 ? "bg-orange-500 text-black border-orange-500 hover:bg-orange-400" : "border-white/20 text-white hover:bg-white/10"}
|
||||||
|
>
|
||||||
|
{totalPages}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => loadMoreTemplates()}
|
||||||
|
disabled={paginationState.total <= paginationState.pageSize || !paginationState.hasMore || paginationState.loading}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-white/20 text-white hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="group border-dashed border-2 border-white/15 bg-white/5 hover:border-white/25 transition-all cursor-pointer" onClick={() => user?.id ? setShowCustomForm(true) : window.location.href = '/signin'}>
|
||||||
<CardContent className="text-center py-16 px-8 text-white/80">
|
<CardContent className="text-center py-16 px-8 text-white/80">
|
||||||
<div className="w-20 h-20 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
<div className="w-20 h-20 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
<Plus className="h-10 w-10 text-orange-400" />
|
<Plus className="h-10 w-10 text-orange-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-white mb-3">Create Custom Template</h3>
|
<h3 className="text-2xl font-bold text-white mb-3">
|
||||||
|
{user?.id ? 'Create Custom Template' : 'Sign In to Create Templates'}
|
||||||
|
</h3>
|
||||||
<p className="mb-8 max-w-md mx-auto text-lg leading-relaxed">
|
<p className="mb-8 max-w-md mx-auto text-lg leading-relaxed">
|
||||||
Don't see what you need? Create a custom project type with your specific requirements and tech stack.
|
{user?.id
|
||||||
|
? "Don't worry, we'll guide you through each step. a custom project type with your specific requirements and tech stack."
|
||||||
|
: "Sign in to create custom project templates with your specific requirements and tech stack."
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
||||||
|
{user?.id ? (
|
||||||
|
<>
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
Create Custom Template
|
Create Custom Template
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<User className="mr-2 h-5 w-5" />
|
||||||
|
Sign In to Create
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Results Summary */}
|
|
||||||
{searchQuery && (
|
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<p className="text-white/70">
|
<p className="text-white/70">
|
||||||
Showing {filteredTemplates.length} template{filteredTemplates.length !== 1 ? "s" : ""}
|
{searchQuery ? (
|
||||||
{searchQuery && ` matching "${searchQuery}"`}
|
<>
|
||||||
|
Showing {templates.length} template{templates.length !== 1 ? "s" : ""} matching "{searchQuery}"
|
||||||
|
{paginationState.total > 0 && ` (${paginationState.total} total)`}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Showing {templates.length} template{templates.length !== 1 ? "s" : ""}
|
||||||
|
{paginationState.total > 0 && ` of ${paginationState.total} total`}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{paginationState.total > 0 && (
|
||||||
|
<span className="block text-sm text-white/50 mt-1">
|
||||||
|
Page {paginationState.currentPage + 1} of {Math.ceil(paginationState.total / paginationState.pageSize)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -447,7 +819,6 @@ function FeatureSelectionStep({
|
|||||||
const [features, setFeatures] = useState<TemplateFeature[]>([])
|
const [features, setFeatures] = useState<TemplateFeature[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [newFeature, setNewFeature] = useState({ name: '', description: '', complexity: 'medium' as 'low' | 'medium' | 'high' })
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [showAIModal, setShowAIModal] = useState(false)
|
const [showAIModal, setShowAIModal] = useState(false)
|
||||||
|
|
||||||
@ -466,26 +837,8 @@ function FeatureSelectionStep({
|
|||||||
// Initial load
|
// Initial load
|
||||||
useEffect(() => { load() }, [template.id])
|
useEffect(() => { load() }, [template.id])
|
||||||
|
|
||||||
const handleAddCustom = async () => {
|
|
||||||
if (!newFeature.name.trim()) return
|
|
||||||
const created = await createFeature(template.id, {
|
|
||||||
name: newFeature.name,
|
|
||||||
description: newFeature.description,
|
|
||||||
feature_type: 'custom',
|
|
||||||
complexity: newFeature.complexity,
|
|
||||||
is_default: false,
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddAIAnalyzed = async (payload: { name: string; description: string; complexity: 'low' | 'medium' | 'high' }) => {
|
const handleAddAIAnalyzed = async (payload: { name: string; description: string; complexity: 'low' | 'medium' | 'high'; logic_rules?: string[] }) => {
|
||||||
await createFeature(template.id, {
|
await createFeature(template.id, {
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
description: payload.description,
|
description: payload.description,
|
||||||
@ -493,17 +846,22 @@ function FeatureSelectionStep({
|
|||||||
complexity: payload.complexity,
|
complexity: payload.complexity,
|
||||||
is_default: false,
|
is_default: false,
|
||||||
created_by_user: true,
|
created_by_user: true,
|
||||||
|
logic_rules: payload.logic_rules,
|
||||||
})
|
})
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [editingFeature, setEditingFeature] = useState<TemplateFeature | null>(null)
|
||||||
|
|
||||||
const handleUpdate = async (f: TemplateFeature, updates: Partial<TemplateFeature>) => {
|
const handleUpdate = async (f: TemplateFeature, updates: Partial<TemplateFeature>) => {
|
||||||
await updateFeature(f.id, { ...updates, isCustom: f.feature_type === 'custom' })
|
const idForApi = f.feature_type === 'custom' ? (f.feature_id?.replace(/^custom_/, '') || f.id) : f.id
|
||||||
|
await updateFeature(idForApi, { ...updates, isCustom: f.feature_type === 'custom' })
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (f: TemplateFeature) => {
|
const handleDelete = async (f: TemplateFeature) => {
|
||||||
await deleteFeature(f.id, { isCustom: f.feature_type === 'custom' })
|
const idForApi = f.feature_type === 'custom' ? (f.feature_id?.replace(/^custom_/, '') || f.id) : f.id
|
||||||
|
await deleteFeature(idForApi, { isCustom: f.feature_type === 'custom' })
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(f.id)
|
next.delete(f.id)
|
||||||
@ -539,13 +897,22 @@ function FeatureSelectionStep({
|
|||||||
</div>
|
</div>
|
||||||
{f.feature_type === 'custom' && (
|
{f.feature_type === 'custom' && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" variant="outline" className="border-white/20 text-white hover:bg-white/10" onClick={async () => {
|
<Button
|
||||||
const newName = window.prompt('Update feature name', f.name || '')
|
size="sm"
|
||||||
if (newName === null) return
|
variant="outline"
|
||||||
const newDesc = window.prompt('Update description', f.description || '')
|
className="border-white/20 text-white hover:bg-white/10"
|
||||||
await handleUpdate(f, { name: newName, description: newDesc ?? f.description })
|
onClick={() => setEditingFeature(f)}
|
||||||
}}>Edit</Button>
|
>
|
||||||
<Button size="sm" variant="outline" className="border-red-500 text-red-300 hover:bg-red-500/10" onClick={() => handleDelete(f)}>Delete</Button>
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-500 text-red-300 hover:bg-red-500/10"
|
||||||
|
onClick={() => handleDelete(f)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@ -583,33 +950,25 @@ function FeatureSelectionStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{section('Essential Features', essentials)}
|
{section('Essential Features', essentials)}
|
||||||
{section('Suggested Features', suggested)}
|
{/* {section('Suggested Features', suggested)} */}
|
||||||
|
|
||||||
{/* Add custom feature */}
|
{/* Add custom feature with AI */}
|
||||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 space-y-3">
|
<div className="bg-white/5 border border-white/10 rounded-xl p-6 space-y-4">
|
||||||
<h3 className="text-white font-semibold">Add Custom Feature</h3>
|
<div className="text-center space-y-2">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
<h3 className="text-white font-semibold text-lg">Add Custom Feature</h3>
|
||||||
<Input placeholder="Feature name" value={newFeature.name} onChange={(e) => setNewFeature({ ...newFeature, name: e.target.value })} className="bg-white/5 border-white/10 text-white placeholder:text-white/40" />
|
<p className="text-white/60 text-sm">Use AI to analyze and create custom features for your project</p>
|
||||||
<Input placeholder="Description (optional)" value={newFeature.description} onChange={(e) => setNewFeature({ ...newFeature, description: e.target.value })} className="bg-white/5 border-white/10 text-white placeholder:text-white/40" />
|
</div>
|
||||||
<select
|
<div className="flex justify-center">
|
||||||
value={newFeature.complexity}
|
<Button
|
||||||
onChange={(e) =>
|
onClick={() => setShowAIModal(true)}
|
||||||
setNewFeature({ ...newFeature, complexity: e.target.value as any })
|
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold px-8 py-3 rounded-lg cursor-pointer"
|
||||||
}
|
|
||||||
className="bg-gray-900 border border-white text-white rounded-md px-3 py-2 focus:ring-2 focus:ring-white focus:border-white cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<option className="bg-gray-900 text-white" value="low">low</option>
|
<Zap className="mr-2 h-5 w-5" />
|
||||||
<option className="bg-gray-900 text-white" value="medium">medium</option>
|
Analyze with AI
|
||||||
<option className="bg-gray-900 text-white" value="high">high</option>
|
</Button>
|
||||||
</select>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center gap-3 flex-wrap">
|
|
||||||
<div className="text-white/60 text-sm">Custom features are saved to this template.</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setShowAIModal(true)} className="border-white/20 text-white hover:bg-white/10 cursor-pointer">Analyze with AI</Button>
|
|
||||||
<Button onClick={handleAddCustom} className="bg-orange-500 hover:bg-orange-400 text-black cursor-pointer">Add Feature</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-center text-white/60 text-sm">
|
||||||
|
AI will analyze your requirements and create optimized features
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -623,6 +982,18 @@ function FeatureSelectionStep({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingFeature && (
|
||||||
|
<EditFeatureForm
|
||||||
|
feature={editingFeature}
|
||||||
|
onSubmit={async (updates) => {
|
||||||
|
await handleUpdate(editingFeature, updates)
|
||||||
|
setEditingFeature(null)
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingFeature(null)}
|
||||||
|
isOpen={!!editingFeature}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<div className="space-x-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>
|
<Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10 cursor-pointer">Back</Button>
|
||||||
@ -652,6 +1023,7 @@ function BusinessQuestionsStep({
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const selectedKey = selected.map(s => s.id).join(',')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@ -662,7 +1034,7 @@ function BusinessQuestionsStep({
|
|||||||
setError('No features selected')
|
setError('No features selected')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const resp = await fetch('http://localhost:8001/api/v1/generate-comprehensive-business-questions', {
|
const resp = await fetch(`${BACKEND_URL}/api/v1/generate-comprehensive-business-questions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -686,7 +1058,7 @@ function BusinessQuestionsStep({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
}, [template.id, selected.map(s => s.id).join(',')])
|
}, [template.id, selectedKey])
|
||||||
|
|
||||||
const answeredCount = Object.values(businessAnswers).filter((a) => a && a.trim()).length
|
const answeredCount = Object.values(businessAnswers).filter((a) => a && a.trim()).length
|
||||||
|
|
||||||
@ -742,7 +1114,7 @@ function BusinessQuestionsStep({
|
|||||||
logicRules: (selected as any[]).flatMap((f: any) => f.logicRules || []),
|
logicRules: (selected as any[]).flatMap((f: any) => f.logicRules || []),
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await fetch('http://localhost:8002/api/v1/select', {
|
const resp = await fetch(`${BACKEND_URL}/api/v1/select`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(completeData),
|
body: JSON.stringify(completeData),
|
||||||
@ -957,6 +1329,7 @@ function AIMockupStep({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
// Load device type
|
// Load device type
|
||||||
const savedDevice = localStorage.getItem('wireframe_device_type')
|
const savedDevice = localStorage.getItem('wireframe_device_type')
|
||||||
if (savedDevice && ['desktop', 'tablet', 'mobile'].includes(savedDevice)) {
|
if (savedDevice && ['desktop', 'tablet', 'mobile'].includes(savedDevice)) {
|
||||||
@ -973,6 +1346,7 @@ function AIMockupStep({
|
|||||||
console.error('Failed to parse saved wireframe data:', error)
|
console.error('Failed to parse saved wireframe data:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleWireframeGenerated = (data: any) => {
|
const handleWireframeGenerated = (data: any) => {
|
||||||
@ -1099,8 +1473,7 @@ function AIMockupStep({
|
|||||||
<Button
|
<Button
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!wireframeData}
|
disabled={!wireframeData}
|
||||||
className={`bg-orange-500 hover:bg-orange-400 text-black font-semibold py-2 rounded-lg shadow ${
|
className={`bg-orange-500 hover:bg-orange-400 text-black font-semibold py-2 rounded-lg shadow ${!wireframeData ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
!wireframeData ? 'opacity-50 cursor-not-allowed' : ''
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Continue to Business Context →
|
Continue to Business Context →
|
||||||
@ -1142,6 +1515,7 @@ export function MainDashboard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
// Load current step
|
// Load current step
|
||||||
const savedStep = localStorage.getItem('dashboard_current_step')
|
const savedStep = localStorage.getItem('dashboard_current_step')
|
||||||
if (savedStep) {
|
if (savedStep) {
|
||||||
@ -1187,6 +1561,7 @@ export function MainDashboard() {
|
|||||||
console.error('Failed to parse saved recommendations:', error)
|
console.error('Failed to parse saved recommendations:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
@ -1385,7 +1760,7 @@ function ArchitectureDesignerStep({ recommendations, onBack }: { recommendations
|
|||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const resp = await fetch('http://localhost:8003/api/v1/design-architecture', {
|
const resp = await fetch(`${BACKEND_URL}/api/v1/design-architecture`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ tech_stack_recommendations: recommendations }),
|
body: JSON.stringify({ tech_stack_recommendations: recommendations }),
|
||||||
|
|||||||
@ -17,14 +17,6 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Bell, Settings, LogOut, User, Menu, X, Shield } from "lucide-react";
|
import { Bell, Settings, LogOut, User, Menu, X, Shield } from "lucide-react";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
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() {
|
export function Header() {
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
@ -64,19 +56,20 @@ export function Header() {
|
|||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<nav className="hidden md:flex space-x-2">
|
<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
|
<Link
|
||||||
key={item.name}
|
key="project-builder"
|
||||||
href={item.href}
|
href="/project-builder"
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
pathname === item.href
|
pathname === "/project-builder"
|
||||||
? "bg-orange-500 text-black"
|
? "bg-orange-500 text-black"
|
||||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.name}
|
Project Builder
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
)}
|
||||||
{/* Admin Navigation */}
|
{/* Admin Navigation */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Link
|
<Link
|
||||||
@ -118,9 +111,9 @@ export function Header() {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
|
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
|
||||||
<Avatar className="h-8 w-8">
|
<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>
|
<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>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
@ -128,7 +121,7 @@ export function Header() {
|
|||||||
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
|
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex flex-col space-y-1">
|
<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>
|
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Badge className="w-fit bg-orange-500 text-black text-xs">
|
<Badge className="w-fit bg-orange-500 text-black text-xs">
|
||||||
@ -172,20 +165,20 @@ export function Header() {
|
|||||||
{mobileMenuOpen && (
|
{mobileMenuOpen && (
|
||||||
<div className="md:hidden py-4 border-t border-white/10">
|
<div className="md:hidden py-4 border-t border-white/10">
|
||||||
<nav className="flex flex-col space-y-2">
|
<nav className="flex flex-col space-y-2">
|
||||||
{navigation.map((item) => (
|
{!isAdmin && (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key="project-builder-mobile"
|
||||||
href={item.href}
|
href="/project-builder"
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
pathname === item.href
|
pathname === "/project-builder"
|
||||||
? "bg-orange-500 text-black"
|
? "bg-orange-500 text-black"
|
||||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
{item.name}
|
Project Builder
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
)}
|
||||||
{/* Admin Navigation for mobile */}
|
{/* Admin Navigation for mobile */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -6,7 +6,6 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
|
||||||
className={cn(
|
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",
|
"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]",
|
"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 React, { createContext, useContext, useEffect, useState } from 'react'
|
||||||
import { safeLocalStorage } from '@/lib/utils'
|
import { safeLocalStorage } from '@/lib/utils'
|
||||||
@ -14,6 +14,7 @@ interface User {
|
|||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
user: User | null
|
user: User | null
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
isLoading: boolean
|
||||||
setUserFromApi: (user: User) => void
|
setUserFromApi: (user: User) => void
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
}
|
}
|
||||||
@ -22,23 +23,78 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
|||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = safeLocalStorage.getItem('codenuk_user')
|
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 {
|
try {
|
||||||
const userData = JSON.parse(stored)
|
const userData = JSON.parse(stored)
|
||||||
|
console.log('🔐 [AuthContext] Restoring user session:', userData?.username)
|
||||||
|
console.log('🔐 [AuthContext] User ID from localStorage:', userData?.id)
|
||||||
setUser(userData)
|
setUser(userData)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse stored user data:', error)
|
console.error('Failed to parse stored user data:', error)
|
||||||
safeLocalStorage.removeItem('codenuk_user')
|
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) => {
|
const setUserFromApi = (u: User) => {
|
||||||
|
console.log('🔐 [AuthContext] Setting user from API:', u)
|
||||||
|
console.log('🔐 [AuthContext] User ID being set:', u?.id)
|
||||||
setUser(u)
|
setUser(u)
|
||||||
safeLocalStorage.setItem('codenuk_user', JSON.stringify(u))
|
safeLocalStorage.setItem('codenuk_user', JSON.stringify(u))
|
||||||
|
console.log('🔐 [AuthContext] User data saved to localStorage')
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
@ -61,7 +117,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, isAdmin: user?.role === 'admin', setUserFromApi, logout }}>
|
<AuthContext.Provider value={{ user, isAdmin: user?.role === 'admin', isLoading, setUserFromApi, logout }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { templateService, DatabaseTemplate, TemplatesByCategory, TemplateFeature } from '@/lib/template-service'
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { templateService, DatabaseTemplate, TemplateFeature, TemplatesByCategory } from '@/lib/template-service';
|
||||||
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
|
||||||
export function useTemplates() {
|
export function useTemplates() {
|
||||||
const [templates, setTemplates] = useState<TemplatesByCategory>({})
|
const { user, isLoading: authLoading } = useAuth();
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Log user ID for debugging
|
||||||
useEffect(() => {
|
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())
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have authentication tokens available
|
||||||
|
const accessToken = localStorage.getItem('accessToken')
|
||||||
|
const refreshToken = localStorage.getItem('refreshToken')
|
||||||
|
const hasTokens = !!(accessToken || refreshToken)
|
||||||
|
|
||||||
|
console.log('[useTemplates] Token check before API call:', {
|
||||||
|
hasAccessToken: !!accessToken,
|
||||||
|
hasRefreshToken: !!refreshToken,
|
||||||
|
hasAnyToken: hasTokens,
|
||||||
|
userExists: !!user,
|
||||||
|
userId: user?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
|
||||||
|
// Check again after waiting
|
||||||
|
const retryAccessToken = localStorage.getItem('accessToken')
|
||||||
|
const retryRefreshToken = localStorage.getItem('refreshToken')
|
||||||
|
const retryHasTokens = !!(retryAccessToken || retryRefreshToken)
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>) => {
|
const createTemplate = async (templateData: Partial<DatabaseTemplate>) => {
|
||||||
try {
|
try {
|
||||||
const newTemplate = await templateService.createTemplate(templateData)
|
const newTemplate = await templateService.createTemplate(templateData);
|
||||||
// Refresh templates after creating a new one
|
// Refresh templates after creation
|
||||||
await fetchTemplates()
|
await fetchTemplatesWithPagination({ resetPagination: true });
|
||||||
return newTemplate
|
return newTemplate;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create template')
|
const errorMessage = err instanceof Error ? err.message : 'Failed to create template';
|
||||||
throw err
|
// 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>) => {
|
const updateTemplate = async (id: string, templateData: Partial<DatabaseTemplate>, isCustom: boolean = false) => {
|
||||||
try {
|
try {
|
||||||
const updatedTemplate = await templateService.updateTemplate(id, templateData)
|
const updatedTemplate = await templateService.updateTemplate(id, templateData, isCustom);
|
||||||
// Refresh templates after updating
|
// Refresh templates after update
|
||||||
await fetchTemplates()
|
await fetchTemplatesWithPagination({ resetPagination: true });
|
||||||
return updatedTemplate
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to update template')
|
const errorMessage = err instanceof Error ? err.message : 'Failed to update template';
|
||||||
throw err
|
show({ title: 'Update failed', description: errorMessage, variant: 'error' });
|
||||||
}
|
throw err;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteTemplate = async (id: string) => {
|
const deleteTemplate = async (id: string, isCustom: boolean = false) => {
|
||||||
try {
|
try {
|
||||||
await templateService.deleteTemplate(id)
|
await templateService.deleteTemplate(id, isCustom);
|
||||||
// Refresh templates after deleting
|
// Refresh templates after deletion
|
||||||
await fetchTemplates()
|
await fetchTemplatesWithPagination({ resetPagination: true });
|
||||||
|
show({ title: 'Template deleted', variant: 'success' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to delete template')
|
const errorMessage = err instanceof Error ? err.message : 'Failed to delete template';
|
||||||
throw err
|
show({ title: 'Delete failed', description: errorMessage, variant: 'error' });
|
||||||
}
|
throw err;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 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
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allTemplates
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get template features when a template is selected
|
|
||||||
const getTemplateFeatures = async (templateId: string): Promise<string[]> => {
|
const getTemplateFeatures = async (templateId: string): Promise<string[]> => {
|
||||||
try {
|
try {
|
||||||
const features = await templateService.getFeaturesForTemplate(templateId)
|
const features = await templateService.getFeaturesForTemplate(templateId);
|
||||||
return features.map(feature => feature.name)
|
return features.map((feature) => feature.name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching template features:', error)
|
console.error('[useTemplates] Error fetching template features:', error);
|
||||||
return []
|
return [];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Feature CRUD for a template
|
|
||||||
const fetchFeatures = async (templateId: string): Promise<TemplateFeature[]> => {
|
const fetchFeatures = async (templateId: string): Promise<TemplateFeature[]> => {
|
||||||
return templateService.getFeaturesForTemplate(templateId)
|
return templateService.getFeaturesForTemplate(templateId);
|
||||||
}
|
};
|
||||||
|
|
||||||
const createFeature = async (templateId: string, feature: Partial<TemplateFeature>) => {
|
const createFeature = async (templateId: string, feature: Partial<TemplateFeature>) => {
|
||||||
const payload = { ...feature, template_id: templateId }
|
const payload = { ...feature, template_id: templateId };
|
||||||
return templateService.createFeature(payload)
|
return templateService.createFeature(payload);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateFeature = async (
|
const updateFeature = async (featureId: string, updates: Partial<TemplateFeature> & { isCustom?: boolean }) => {
|
||||||
featureId: string,
|
return templateService.updateFeature(featureId, updates);
|
||||||
updates: Partial<TemplateFeature> & { isCustom?: boolean }
|
};
|
||||||
) => {
|
|
||||||
// If updates indicate custom, route accordingly
|
|
||||||
return templateService.updateFeature(featureId, updates)
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteFeature = async (featureId: string, opts?: { isCustom?: boolean }) => {
|
const deleteFeature = async (featureId: string, opts?: { isCustom?: boolean }) => {
|
||||||
return templateService.deleteFeature(featureId, opts)
|
return templateService.deleteFeature(featureId, opts);
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
templates,
|
user,
|
||||||
loading,
|
combined,
|
||||||
|
loading: loading || authLoading,
|
||||||
error,
|
error,
|
||||||
fetchTemplates,
|
paginationState,
|
||||||
|
categories,
|
||||||
|
fetchTemplatesWithPagination,
|
||||||
|
loadMoreTemplates,
|
||||||
createTemplate,
|
createTemplate,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
deleteTemplate,
|
deleteTemplate,
|
||||||
getTemplatesForUI,
|
|
||||||
getTemplateFeatures,
|
getTemplateFeatures,
|
||||||
fetchFeatures,
|
fetchFeatures,
|
||||||
createFeature,
|
createFeature,
|
||||||
updateFeature,
|
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,
|
FeatureReviewData,
|
||||||
AdminStats,
|
AdminStats,
|
||||||
FeatureSynonym,
|
FeatureSynonym,
|
||||||
AdminApiResponse
|
AdminApiResponse,
|
||||||
|
AdminTemplate
|
||||||
} from '@/types/admin.types';
|
} from '@/types/admin.types';
|
||||||
import { getAccessToken } from '@/components/apis/authApiClients';
|
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
|
// Helper function to make API calls
|
||||||
async function apiCall<T>(
|
async function apiCall<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options: RequestInit = {}
|
options: RequestInit = {},
|
||||||
|
useAuthAPI = false
|
||||||
): Promise<AdminApiResponse<T>> {
|
): 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 token = getAccessToken();
|
||||||
const defaultHeaders: Record<string, string> = {
|
const defaultHeaders: Record<string, string> = {
|
||||||
@ -36,7 +41,14 @@ async function apiCall<T>(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
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();
|
return response.json();
|
||||||
@ -46,42 +58,42 @@ async function apiCall<T>(
|
|||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
// Get pending features
|
// Get pending features
|
||||||
getPendingFeatures: async (limit = 50, offset = 0): Promise<AdminFeature[]> => {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get features by status
|
// Get features by status
|
||||||
getFeaturesByStatus: async (status: string, limit = 50, offset = 0): Promise<AdminFeature[]> => {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get feature statistics
|
// Get feature statistics
|
||||||
getFeatureStats: async (): Promise<AdminStats> => {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Review a feature
|
// Review a feature
|
||||||
reviewFeature: async (featureId: string, reviewData: FeatureReviewData): Promise<AdminFeature> => {
|
reviewFeature: async (featureId: string, reviewData: FeatureReviewData): Promise<AdminFeature> => {
|
||||||
const response = await apiCall<AdminFeature>(`/api/admin/features/${featureId}/review`, {
|
const response = await apiCall<AdminFeature>(`/api/auth/admin/custom-features/${featureId}/review`, {
|
||||||
method: 'POST',
|
method: 'PUT',
|
||||||
body: JSON.stringify(reviewData),
|
body: JSON.stringify(reviewData),
|
||||||
});
|
}, true);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Find similar features
|
// Find similar features
|
||||||
findSimilarFeatures: async (query: string, threshold = 0.7, limit = 5): Promise<FeatureSimilarity[]> => {
|
findSimilarFeatures: async (query: string, threshold = 0.7, limit = 5): Promise<FeatureSimilarity[]> => {
|
||||||
const response = await apiCall<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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Add feature synonym
|
// Add feature synonym
|
||||||
addFeatureSynonym: async (featureId: string, synonym: string, createdBy?: string): Promise<FeatureSynonym> => {
|
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',
|
method: 'POST',
|
||||||
body: JSON.stringify({ synonym, created_by: createdBy }),
|
body: JSON.stringify({ synonym, created_by: createdBy }),
|
||||||
});
|
});
|
||||||
@ -90,7 +102,7 @@ export const adminApi = {
|
|||||||
|
|
||||||
// Get feature synonyms
|
// Get feature synonyms
|
||||||
getFeatureSynonyms: async (featureId: string): Promise<FeatureSynonym[]> => {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -117,6 +129,272 @@ export const adminApi = {
|
|||||||
});
|
});
|
||||||
return response.data;
|
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)
|
// Feature submission API (for regular users)
|
||||||
@ -127,10 +405,10 @@ export const featureApi = {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
complexity: 'low' | 'medium' | 'high';
|
complexity: 'low' | 'medium' | 'high';
|
||||||
business_rules?: any;
|
business_rules?: Record<string, unknown>;
|
||||||
technical_requirements?: any;
|
technical_requirements?: Record<string, unknown>;
|
||||||
created_by_user_session?: string;
|
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', {
|
const response = await apiCall<AdminFeature>('/api/features/custom', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(featureData),
|
body: JSON.stringify(featureData),
|
||||||
@ -138,7 +416,7 @@ export const featureApi = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
data: response.data,
|
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
|
// User Authentication Service
|
||||||
auth: {
|
auth: {
|
||||||
baseUrl: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8011',
|
baseUrl: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8000',
|
||||||
endpoints: {
|
endpoints: {
|
||||||
health: '/health',
|
health: '/health',
|
||||||
register: '/api/auth/register',
|
register: '/api/auth/register',
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
import { getAccessToken } from '@/components/apis/authApiClients'
|
||||||
|
import { BACKEND_URL } from '@/config/backend'
|
||||||
|
|
||||||
export interface DatabaseTemplate {
|
export interface DatabaseTemplate {
|
||||||
id: string
|
id: string
|
||||||
type: string
|
type: string
|
||||||
@ -14,6 +17,8 @@ export interface DatabaseTemplate {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
feature_count?: number
|
feature_count?: number
|
||||||
avg_rating?: 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 {
|
export interface TemplateFeature {
|
||||||
@ -41,19 +46,36 @@ export interface TemplatesByCategory {
|
|||||||
[category: string]: DatabaseTemplate[]
|
[category: string]: DatabaseTemplate[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE_URL = 'http://localhost:8009'
|
|
||||||
|
|
||||||
class TemplateService {
|
class TemplateService {
|
||||||
private async makeRequest<T>(endpoint: string): Promise<T> {
|
private async makeRequest<T>(endpoint: string, requireAuth: boolean = false): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
const headers: Record<string, string> = {
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
'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,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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}`)
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,29 +93,274 @@ class TemplateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTemplatesByCategory(): Promise<TemplatesByCategory> {
|
async getTemplatesByCategory(): Promise<TemplatesByCategory> {
|
||||||
return this.makeRequest<TemplatesByCategory>('/api/templates')
|
return this.makeRequest<TemplatesByCategory>('/api/templates', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTemplateById(id: string): Promise<TemplateWithFeatures> {
|
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> {
|
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> {
|
async createTemplate(templateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> {
|
||||||
|
console.log(templateData, "templateData___");
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/templates`, {
|
const headers: Record<string, string> = {
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
}
|
||||||
body: JSON.stringify(templateData),
|
|
||||||
|
// 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,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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()
|
const data = await response.json()
|
||||||
@ -111,7 +378,7 @@ class TemplateService {
|
|||||||
|
|
||||||
// Features API
|
// Features API
|
||||||
async getFeaturesForTemplate(templateId: string): Promise<TemplateFeature[]> {
|
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 dedupe = (items: TemplateFeature[]) => {
|
||||||
const byKey = new Map<string, TemplateFeature>()
|
const byKey = new Map<string, TemplateFeature>()
|
||||||
|
|
||||||
@ -152,8 +419,8 @@ class TemplateService {
|
|||||||
const merged = await this.makeRequest<TemplateFeature[]>(`/api/templates/${templateId}/features`)
|
const merged = await this.makeRequest<TemplateFeature[]>(`/api/templates/${templateId}/features`)
|
||||||
return dedupe(merged)
|
return dedupe(merged)
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to default-only if merged endpoint unsupported
|
// Fallback to compatible route in features router
|
||||||
const defaults = await this.makeRequest<TemplateFeature[]>(`/api/features/templates/${templateId}/merged`)
|
const defaults = await this.makeRequest<TemplateFeature[]>(`/api/features/templates/${templateId}/features`)
|
||||||
return dedupe(defaults)
|
return dedupe(defaults)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -172,11 +439,16 @@ class TemplateService {
|
|||||||
async createFeature(featureData: Partial<TemplateFeature>): Promise<TemplateFeature> {
|
async createFeature(featureData: Partial<TemplateFeature>): Promise<TemplateFeature> {
|
||||||
if (
|
if (
|
||||||
featureData &&
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: customHeaders,
|
||||||
body: JSON.stringify(featureData),
|
body: JSON.stringify(featureData),
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
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')
|
if (!data.success) throw new Error(data.message || 'Failed to create custom feature')
|
||||||
return data.data
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
body: JSON.stringify(featureData),
|
body: JSON.stringify(featureData),
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
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> {
|
async updateFeature(id: string, featureData: Partial<TemplateFeature> & { isCustom?: boolean }): Promise<TemplateFeature> {
|
||||||
const isCustom = (featureData as any).isCustom || featureData.feature_type === 'custom'
|
const isCustom = featureData.isCustom || featureData.feature_type === 'custom'
|
||||||
const url = isCustom ? `${API_BASE_URL}/api/features/custom/${id}` : `${API_BASE_URL}/api/features/${id}`
|
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, {
|
const response = await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
body: JSON.stringify(featureData),
|
body: JSON.stringify(featureData),
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
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> {
|
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, {
|
const response = await fetch(url, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (!data.success) throw new Error(data.message || 'Failed to delete feature')
|
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 {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/templates/${id}`, {
|
const headers: Record<string, string> = {
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
'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,
|
||||||
body: JSON.stringify(templateData),
|
body: JSON.stringify(templateData),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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()
|
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 {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/templates/${id}`, {
|
const headers: Record<string, string> = {
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
'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,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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()
|
const data = await response.json()
|
||||||
|
|||||||
@ -9,13 +9,14 @@ export async function analyzeFeatureWithAI(
|
|||||||
featureName: string,
|
featureName: string,
|
||||||
description: string,
|
description: string,
|
||||||
requirements: string[],
|
requirements: string[],
|
||||||
projectType?: string
|
projectType?: string,
|
||||||
|
userSelectedComplexity?: Complexity
|
||||||
): Promise<AIAnalysisResponse> {
|
): Promise<AIAnalysisResponse> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai/analyze', {
|
const res = await fetch('/api/ai/analyze', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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()
|
const json = await res.json()
|
||||||
if (!res.ok || !json.success) {
|
if (!res.ok || !json.success) {
|
||||||
|
|||||||
@ -119,3 +119,33 @@ export interface AdminPagination {
|
|||||||
limit: number;
|
limit: number;
|
||||||
total: 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