954 lines
38 KiB
TypeScript
954 lines
38 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Tooltip } from '@/components/ui/tooltip'
|
|
import { Button } from '@/components/ui/button'
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
|
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,
|
|
ChevronLeft,
|
|
ChevronRight
|
|
} from 'lucide-react'
|
|
import { adminApi, formatDate, getComplexityColor } from '@/lib/api/admin'
|
|
import { BACKEND_URL } from '@/config/backend'
|
|
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)
|
|
const [showEditModal, setShowEditModal] = useState(false)
|
|
const [editingTemplate, setEditingTemplate] = useState<AdminTemplate | null>(null)
|
|
// Pagination state
|
|
const [page, setPage] = useState(1)
|
|
const [limit, setLimit] = useState(6)
|
|
const [hasMore, setHasMore] = useState(false)
|
|
const [totalTemplates, setTotalTemplates] = useState<number | null>(null)
|
|
|
|
// 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 offset = (page - 1) * limit
|
|
const effectiveCategory = categoryFilter === 'all' ? undefined : categoryFilter
|
|
const [templatesResponse, statsResponse] = await Promise.all([
|
|
adminApi.getAdminTemplates(limit, offset, effectiveCategory, searchQuery),
|
|
adminApi.getAdminTemplateStats()
|
|
])
|
|
|
|
console.log('Admin templates response:', templatesResponse)
|
|
console.log('Admin template stats response:', statsResponse)
|
|
|
|
setTemplates((templatesResponse?.templates) || [])
|
|
setHasMore(Boolean(templatesResponse?.pagination?.hasMore ?? ((templatesResponse?.templates?.length || 0) === limit)))
|
|
setTotalTemplates(templatesResponse?.pagination?.total ?? (statsResponse as any)?.total_templates ?? null)
|
|
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()
|
|
}, [page, 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 edit template
|
|
const handleEditTemplate = (template: AdminTemplate) => {
|
|
setEditingTemplate(template)
|
|
setShowEditModal(true)
|
|
}
|
|
|
|
// Handle delete template
|
|
const handleDeleteTemplate = async (template: AdminTemplate) => {
|
|
if (!confirm(`Delete template "${template.title}"? This cannot be undone.`)) return
|
|
try {
|
|
await adminApi.deleteAdminTemplate(template.id)
|
|
await loadData()
|
|
} catch (err) {
|
|
console.error('Delete failed', err)
|
|
alert('Failed to delete template')
|
|
}
|
|
}
|
|
|
|
// 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(`${BACKEND_URL}/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 'food delivery':
|
|
return ShoppingCart
|
|
case 'e-commerce':
|
|
case 'ecommerce':
|
|
return ShoppingCart
|
|
case 'saas platform':
|
|
return Code
|
|
case 'mobile app':
|
|
return Code
|
|
case 'dashboard':
|
|
return BarChart3
|
|
case 'crm system':
|
|
return Briefcase
|
|
case 'learning platform':
|
|
return GraduationCap
|
|
case 'healthcare':
|
|
return AlertCircle
|
|
case 'real estate':
|
|
return Globe
|
|
case 'travel':
|
|
return Globe
|
|
case 'entertainment':
|
|
return Zap
|
|
case 'finance':
|
|
return BarChart3
|
|
case 'social media':
|
|
return Zap
|
|
case 'marketplace':
|
|
return ShoppingCart
|
|
default:
|
|
return Globe
|
|
}
|
|
}
|
|
|
|
// Get category stats for filters
|
|
const getCategoryStats = () => {
|
|
// Start with "All Templates"
|
|
const categoryStats = [
|
|
{ id: 'all', name: 'All Templates', count: templates.length, icon: Globe }
|
|
]
|
|
|
|
// Add categories based on your actual categories array
|
|
categories.forEach(category => {
|
|
if (category !== 'Other') { // Skip 'Other' as it will be handled separately
|
|
categoryStats.push({
|
|
id: category.toLowerCase().replace(/\s+/g, '-'),
|
|
name: category,
|
|
count: 0,
|
|
icon: getCategoryIcon(category)
|
|
})
|
|
}
|
|
})
|
|
|
|
// Add 'Other' category at the end
|
|
categoryStats.push({
|
|
id: 'other',
|
|
name: 'Other',
|
|
count: 0,
|
|
icon: Globe
|
|
})
|
|
|
|
// Count templates by category
|
|
templates.forEach(template => {
|
|
const templateCategory = template.category
|
|
if (templateCategory) {
|
|
const categoryItem = categoryStats.find(cat =>
|
|
cat.name.toLowerCase() === templateCategory.toLowerCase() ||
|
|
cat.id === templateCategory.toLowerCase().replace(/\s+/g, '-')
|
|
)
|
|
if (categoryItem) {
|
|
categoryItem.count++
|
|
} else {
|
|
// If category doesn't match any predefined category, add to 'Other'
|
|
const otherCategory = categoryStats.find(cat => cat.id === 'other')
|
|
if (otherCategory) {
|
|
otherCategory.count++
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
return categoryStats
|
|
}
|
|
|
|
// Filter templates based on search and category
|
|
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())
|
|
|
|
const matchesCategory = categoryFilter === 'all' ||
|
|
template.category?.toLowerCase() === categoryFilter.toLowerCase() ||
|
|
template.category?.toLowerCase().replace(/\s+/g, '-') === categoryFilter ||
|
|
(categoryFilter === 'other' && !categories.some(cat =>
|
|
cat.toLowerCase() === template.category?.toLowerCase()
|
|
))
|
|
|
|
return matchesSearch && matchesCategory
|
|
})
|
|
|
|
const MAX_TITLE_CHARS = 25
|
|
const MAX_DESCRIPTION_PREVIEW_CHARS = 90
|
|
|
|
const TemplateCard = ({ template }: { template: AdminTemplate }) => {
|
|
const [descExpanded, setDescExpanded] = useState(false)
|
|
|
|
const title = template.title || ''
|
|
const truncatedTitle = title.length > MAX_TITLE_CHARS ? `${title.slice(0, MAX_TITLE_CHARS - 1)}…` : title
|
|
|
|
const fullDesc = template.description || ''
|
|
const needsClamp = fullDesc.length > MAX_DESCRIPTION_PREVIEW_CHARS
|
|
const shownDesc = descExpanded || !needsClamp
|
|
? fullDesc
|
|
: `${fullDesc.slice(0, MAX_DESCRIPTION_PREVIEW_CHARS)}…`
|
|
|
|
return (
|
|
<Card
|
|
className="group hover:shadow-md transition-all bg-gray-900 border-gray-800 cursor-pointer"
|
|
onClick={() => handleManageFeatures(template)}
|
|
tabIndex={0}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleManageFeatures(template) } }}
|
|
>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="space-y-2 flex-1 min-w-0">
|
|
<CardTitle className="text-lg text-white group-hover:text-orange-400 transition-colors">
|
|
<Tooltip content={title}>
|
|
<span title={undefined} className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap">{truncatedTitle}</span>
|
|
</Tooltip>
|
|
</CardTitle>
|
|
<div className="flex flex-wrap items-center gap-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>
|
|
<div className="flex items-center space-x-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
<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>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" className="border-white/10 text-white/80 hover:bg-white/10">⋮</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="bg-gray-900 border-gray-800 text-white">
|
|
<DropdownMenuItem onClick={() => handleEditTemplate(template)} className="cursor-pointer">Edit</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleDeleteTemplate(template)} className="text-red-400 focus:text-red-400 cursor-pointer">Delete</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
{fullDesc && (
|
|
<div className="text-white/70 text-sm break-words hyphens-auto">
|
|
<span>{shownDesc}</span>
|
|
{needsClamp && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); setDescExpanded(v => !v) }}
|
|
className="ml-2 text-orange-400 hover:text-orange-300 underline underline-offset-4"
|
|
>
|
|
{descExpanded ? 'Show less' : 'Show more'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</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">{totalTemplates ?? (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); setPage(1) }}
|
|
className="pl-10 bg-white/5 border-white/10 text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Select value={categoryFilter} onValueChange={(v) => { setCategoryFilter(v); setPage(1) }}>
|
|
<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 removed; using only dropdown above */}
|
|
|
|
{/* 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>
|
|
|
|
{/* Edit Template Modal */}
|
|
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
|
<DialogContent className="bg-gray-900 border-gray-800 text-white max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-white">Edit Template</DialogTitle>
|
|
</DialogHeader>
|
|
{editingTemplate && (
|
|
<form
|
|
className="space-y-4"
|
|
onSubmit={async (e) => {
|
|
e.preventDefault()
|
|
const form = e.currentTarget as HTMLFormElement
|
|
const formData = new FormData(form)
|
|
const payload = {
|
|
title: String(formData.get('title') || ''),
|
|
description: String(formData.get('description') || ''),
|
|
category: String(formData.get('category') || ''),
|
|
type: String(formData.get('type') || ''),
|
|
icon: String(formData.get('icon') || ''),
|
|
gradient: String(formData.get('gradient') || ''),
|
|
border: String(formData.get('border') || ''),
|
|
text: String(formData.get('text') || ''),
|
|
subtext: String(formData.get('subtext') || ''),
|
|
}
|
|
try {
|
|
await adminApi.updateAdminTemplate(editingTemplate.id, payload)
|
|
setShowEditModal(false)
|
|
setEditingTemplate(null)
|
|
await loadData()
|
|
} catch (err) {
|
|
console.error('Update failed', err)
|
|
alert('Failed to update template')
|
|
}
|
|
}}
|
|
>
|
|
<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">Title *</label>
|
|
<Input name="title" defaultValue={editingTemplate.title || ''} className="bg-white/5 border-white/10 text-white" required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-white">Type *</label>
|
|
<Input name="type" defaultValue={editingTemplate.type || ''} className="bg-white/5 border-white/10 text-white" required />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-white">Description *</label>
|
|
<textarea name="description" defaultValue={editingTemplate.description || ''} className="w-full px-3 py-2 bg-white/5 border border-white/10 text-white 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 defaultValue={editingTemplate.category || ''} onValueChange={(v) => {
|
|
const input = document.querySelector<HTMLInputElement>('input[name=\'category\']')
|
|
if (input) input.value = v
|
|
}}>
|
|
<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={`edit-category-${index}`} value={category} className="text-white">
|
|
{category}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<input type="hidden" name="category" defaultValue={editingTemplate.category || ''} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-white">Icon</label>
|
|
<Input name="icon" defaultValue={editingTemplate.icon || ''} className="bg-white/5 border-white/10 text-white" />
|
|
</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</label>
|
|
<Input name="gradient" defaultValue={editingTemplate.gradient || ''} className="bg-white/5 border-white/10 text-white" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-white">Border</label>
|
|
<Input name="border" defaultValue={editingTemplate.border || ''} className="bg-white/5 border-white/10 text-white" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-white">Text Color</label>
|
|
<Input name="text" defaultValue={editingTemplate.text || ''} className="bg-white/5 border-white/10 text-white" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-white">Subtext</label>
|
|
<Input name="subtext" defaultValue={editingTemplate.subtext || ''} className="bg-white/5 border-white/10 text-white" />
|
|
</div>
|
|
|
|
<div className="flex justify-end space-x-2 pt-2">
|
|
<Button type="button" variant="outline" onClick={() => setShowEditModal(false)} className="border-white/20 text-white">Cancel</Button>
|
|
<Button type="submit" className="bg-orange-500 text-black hover:bg-orange-600">Save</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>
|
|
)}
|
|
{/* Pagination Controls */}
|
|
<div className="flex items-center justify-center pt-4">
|
|
{(() => {
|
|
const totalFromStats = (totalTemplates ?? (stats as any)?.total_templates) as number | undefined
|
|
const totalPages = totalFromStats
|
|
? Math.max(1, Math.ceil(totalFromStats / limit))
|
|
: (hasMore ? page + 1 : page)
|
|
|
|
const createRange = (current: number, total: number): (number | string)[] => {
|
|
const range: (number | string)[] = []
|
|
const siblingCount = 1
|
|
const firstPage = 1
|
|
const lastPage = total
|
|
const startPage = Math.max(firstPage, current - siblingCount)
|
|
const endPage = Math.min(lastPage, current + siblingCount)
|
|
|
|
if (startPage > firstPage + 1) {
|
|
range.push(firstPage, '...')
|
|
} else {
|
|
for (let i = firstPage; i < startPage; i++) range.push(i)
|
|
}
|
|
|
|
for (let i = startPage; i <= endPage; i++) range.push(i)
|
|
|
|
if (endPage < lastPage - 1) {
|
|
range.push('...', lastPage)
|
|
} else {
|
|
for (let i = endPage + 1; i <= lastPage; i++) range.push(i)
|
|
}
|
|
return range
|
|
}
|
|
|
|
const items = createRange(page, totalPages)
|
|
|
|
return (
|
|
<div className="flex items-center space-x-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
aria-label="Previous page"
|
|
disabled={page === 1}
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
className="h-9 w-9 p-0 rounded-md bg-black/40 border-white/10 text-white hover:bg-white/10 disabled:opacity-50"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{items.map((it, idx) =>
|
|
typeof it === 'number' ? (
|
|
<button
|
|
key={`pg-${it}-${idx}`}
|
|
aria-current={it === page ? 'page' : undefined}
|
|
onClick={() => setPage(it)}
|
|
className={
|
|
`h-9 min-w-9 px-3 inline-flex items-center justify-center rounded-md border ${
|
|
it === page
|
|
? 'bg-orange-500 text-black font-semibold border-orange-400/60'
|
|
: 'bg-black/40 text-white border-white/10 hover:bg-white/10'
|
|
}`
|
|
}
|
|
>
|
|
{it}
|
|
</button>
|
|
) : (
|
|
<div
|
|
key={`ellipsis-${idx}`}
|
|
className="h-9 min-w-9 px-3 inline-flex items-center justify-center rounded-md bg-black/30 text-white/70 border border-white/10"
|
|
>
|
|
…
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
aria-label="Next page"
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
className="h-9 w-9 p-0 rounded-md bg-black/40 border-white/10 text-white hover:bg-white/10 disabled:opacity-50"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
)
|
|
}
|