initial commit

This commit is contained in:
Kar k1
2025-08-30 18:18:57 +05:30
commit 7219108342
270 changed files with 70221 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
/* eslint-disable react-hooks/rules-of-hooks */
'use client'
import '@blocknote/core/fonts/inter.css'
import { BlockNoteView } from '@blocknote/mantine'
import '@blocknote/mantine/style.css'
import { useCreateBlockNote } from '@blocknote/react'
import { Block, BlockNoteEditor, PartialBlock } from '@blocknote/core'
import './styles.css'
interface BlockNoteEditorLocalProps {
onDataChange: (blocks: Block[], html: string) => void
initialContent?: PartialBlock[] | undefined
onImageUpload?: (imageUrl: string) => void
onImageDelete?: (imageUrl: string) => void
}
async function uploadFile(file: File) {
const body = new FormData()
body.append('file', file)
const response = await fetch('/api/topic-content-image', {
method: 'POST',
body: body,
})
const result = await response.json()
if (!result.success) {
throw new Error(result.message || 'Upload failed')
}
return result.data.url
}
export default function BlockNoteEditorLocal({
onDataChange,
initialContent,
onImageUpload,
onImageDelete,
}: BlockNoteEditorLocalProps) {
let editor: BlockNoteEditor
try {
editor = useCreateBlockNote({
initialContent: initialContent,
uploadFile: async (file: File) => {
const url = await uploadFile(file)
if (onImageUpload) {
onImageUpload(url)
}
return url
},
})
} catch (error) {
editor = useCreateBlockNote({
initialContent: undefined,
uploadFile: async (file: File) => {
const url = await uploadFile(file)
if (onImageUpload) {
onImageUpload(url)
}
return url
},
})
}
const handleOnChange = async () => {
const blocks = editor.document
const html = await editor.blocksToFullHTML(blocks)
// Check for deleted images
if (onImageDelete && initialContent) {
const currentImages = blocks
.filter((block) => block.type === 'image')
.map((block) => block.props.url)
const initialImages = initialContent
.filter((block) => block.type === 'image')
.map((block) => block.props.url)
// Find images that were in initialContent but not in current content
const deletedImages = initialImages.filter((url) => !currentImages.includes(url))
deletedImages.forEach((url) => onImageDelete(url))
}
onDataChange(blocks, html)
}
return <BlockNoteView onChange={handleOnChange} editor={editor} className={'block-note-editor'} />
}

View File

@@ -0,0 +1 @@
export { default } from './BlockNoteEditor'

View File

@@ -0,0 +1,5 @@
.block-note-editor {
.bn-editor {
min-height: 30vh;
}
}

81
components/Features.tsx Normal file
View File

@@ -0,0 +1,81 @@
import { Shield, Clock, Headphones, Award, Globe, Lock, Zap, Users } from 'lucide-react'
const Features = () => {
const features = [
{
icon: Shield,
title: '99.9% Uptime SLA',
description: 'Industry-leading uptime guarantee with compensation for outages',
},
{
icon: Clock,
title: '24/7 Support',
description: 'Round-the-clock technical support from hosting experts',
},
{
icon: Lock,
title: 'Enterprise Security',
description: 'DDoS protection, SSL certificates, and security monitoring',
},
{
icon: Zap,
title: 'Lightning Fast',
description: 'SSD storage, CDN, and optimized server configurations',
},
{
icon: Globe,
title: 'Global Infrastructure',
description: 'Data centers worldwide for optimal performance',
},
{
icon: Users,
title: 'Developer Friendly',
description: 'APIs, documentation, and tools built for developers',
},
{
icon: Award,
title: 'Industry Recognition',
description: 'Trusted by 50,000+ developers and businesses worldwide',
},
{
icon: Headphones,
title: 'Expert Consultation',
description: 'Human developer consulting for complex projects',
},
]
return (
<section className="py-24 bg-background">
<div className="container mx-auto px-4">
<div className="text-center mb-16">
<h2 className="text-4xl font-bold mb-4">Why Choose SiliconPin?</h2>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Built by developers, for developers. We understand what you need to succeed.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
{features.map((feature, index) => {
const Icon = feature.icon
return (
<div
key={index}
className="text-center group hover:scale-105 transition-transform duration-300"
>
<div className="w-16 h-16 bg-gradient-primary rounded-xl flex items-center justify-center mx-auto mb-6 group-hover:shadow-glow transition-shadow duration-300">
<Icon className="w-8 h-8 text-white" />
</div>
<h3 className="text-lg font-semibold mb-3">{feature.title}</h3>
<p className="text-muted-foreground text-sm leading-relaxed">
{feature.description}
</p>
</div>
)
})}
</div>
</div>
</section>
)
}
export default Features

94
components/Hero.tsx Normal file
View File

@@ -0,0 +1,94 @@
import { Button } from '@/components/ui/button'
import { ArrowRight, Play, Shield, Zap, Globe } from 'lucide-react'
const Hero = () => {
return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
{/* Background */}
<div className="absolute inset-0">
<div className="w-full h-full bg-gradient-to-br from-primary/20 via-secondary/10 to-primary/20" />
<div className="absolute inset-0 bg-gradient-to-br from-primary/90 via-secondary/80 to-primary/90" />
</div>
{/* Content */}
<div className="relative z-10 container mx-auto px-4 py-20">
<div className="max-w-4xl mx-auto text-center text-white">
{/* Trust Badges */}
<div className="flex items-center justify-center space-x-6 mb-8 text-sm opacity-90">
<div className="flex items-center space-x-2">
<Shield className="w-4 h-4" />
<span>99.9% Uptime</span>
</div>
<div className="flex items-center space-x-2">
<Zap className="w-4 h-4" />
<span>SSD Storage</span>
</div>
<div className="flex items-center space-x-2">
<Globe className="w-4 h-4" />
<span>Global CDN</span>
</div>
</div>
{/* Main Heading */}
<h1 className="text-5xl md:text-7xl font-bold mb-6 leading-tight text-balance">
Professional
<span className="block bg-gradient-to-r from-white to-primary-glow bg-clip-text text-transparent">
Hosting Solutions
</span>
</h1>
{/* Subtitle */}
<p className="text-xl md:text-2xl mb-8 text-white/90 max-w-3xl mx-auto leading-relaxed text-balance font-medium">
VPS, Kubernetes, Developer Services, and Web Tools. Everything you need to build,
deploy, and scale your applications with confidence.
</p>
{/* CTAs */}
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6 mb-12">
<Button variant="glass" size="xl" className="group">
Start Free Trial
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</Button>
<Button
variant="outline"
size="xl"
className="bg-white/10 border-white/30 text-white hover:bg-white/20"
>
<Play className="w-5 h-5" />
Watch Demo
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 pt-8 border-t border-white/20">
<div className="text-center">
<div className="text-3xl font-bold mb-1">50k+</div>
<div className="text-sm opacity-80">Active Users</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold mb-1">99.9%</div>
<div className="text-sm opacity-80">Uptime SLA</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold mb-1">24/7</div>
<div className="text-sm opacity-80">Support</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold mb-1">5+ Years</div>
<div className="text-sm opacity-80">Experience</div>
</div>
</div>
</div>
</div>
{/* Scroll Indicator */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 text-white/60">
<div className="animate-bounce">
<ArrowRight className="w-6 h-6 rotate-90" />
</div>
</div>
</section>
)
}
export default Hero

19
components/Logo.tsx Normal file
View File

@@ -0,0 +1,19 @@
// components/Logo.tsx
export function Logo({ size = 32 }: { size?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 150 150"
fill="none"
className="logo-svg" // Add a class for easier identification
>
<rect width="50" height="50" fill="#FF0000" />
<rect y="100" width="50" height="50" fill="#0000FF" />
<rect x="100" width="50" height="50" fill="#FF0000" />
<rect x="100" y="100" width="50" height="50" fill="#0000FF" />
<rect x="50" y="50" width="50" height="50" fill="#008000" />
</svg>
)
}

View File

@@ -0,0 +1,137 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { X, Download } from 'lucide-react'
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[]
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed'
platform: string
}>
prompt(): Promise<void>
}
export default function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [showPrompt, setShowPrompt] = useState(false)
const [isIOS, setIsIOS] = useState(false)
const [isStandalone, setIsStandalone] = useState(false)
useEffect(() => {
// Check if running on iOS
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
setIsIOS(iOS)
// Check if already installed (standalone mode)
const standalone =
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true
setIsStandalone(standalone)
// Don't show prompt if already installed
if (standalone) return
// Handle beforeinstallprompt event (Android/Chrome)
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e as BeforeInstallPromptEvent)
// Show prompt after a short delay to avoid being too aggressive
setTimeout(() => {
setShowPrompt(true)
}, 3000)
}
// Handle app installed event
const handleAppInstalled = () => {
setShowPrompt(false)
setDeferredPrompt(null)
}
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)
// For iOS, show prompt after delay if not already installed
if (iOS && !standalone) {
setTimeout(() => {
setShowPrompt(true)
}, 5000)
}
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handleAppInstalled)
}
}, [])
const handleInstallClick = async () => {
if (deferredPrompt) {
// Android/Chrome install
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'accepted') {
setDeferredPrompt(null)
setShowPrompt(false)
}
}
// For iOS, the prompt just shows instructions
}
const handleDismiss = () => {
setShowPrompt(false)
// Don't show again for this session
sessionStorage.setItem('pwa-prompt-dismissed', 'true')
}
// Don't show if already dismissed in this session
useEffect(() => {
const dismissed = sessionStorage.getItem('pwa-prompt-dismissed')
if (dismissed) {
setShowPrompt(false)
}
}, [])
// Don't render if already installed or shouldn't show
if (isStandalone || !showPrompt) return null
return (
<div className="fixed bottom-4 left-4 right-4 z-50 md:left-auto md:right-4 md:max-w-sm">
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-2">
<Download className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-sm">Install SiliconPin</h3>
</div>
<Button variant="ghost" size="sm" onClick={handleDismiss} className="h-6 w-6 p-0">
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
{isIOS
? 'Add to your home screen for a better experience'
: 'Install our app for quick access and offline use'}
</p>
{isIOS ? (
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Tap the share button <span className="inline-block">📤</span> and select "Add to Home
Screen"
</div>
) : null}
<div className="flex space-x-2">
<Button onClick={handleInstallClick} size="sm" className="flex-1">
{isIOS ? 'Learn How' : 'Install'}
</Button>
<Button variant="outline" size="sm" onClick={handleDismiss}>
Later
</Button>
</div>
</div>
</div>
)
}

136
components/RecentTopics.tsx Normal file
View File

@@ -0,0 +1,136 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { ArrowRight, BookOpen, TrendingUp, Code, Globe, Zap } from 'lucide-react'
// Static content for homepage
export default function RecentTopics() {
return (
<section className="py-20 bg-muted/30">
<div className="container">
<div className="max-w-7xl mx-auto">
{/* Section Header */}
<div className="text-center mb-12">
<div className="flex items-center justify-center gap-3 mb-4">
<div className="p-3 bg-primary/10 rounded-full">
<TrendingUp className="w-6 h-6 text-primary" />
</div>
</div>
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Latest from Our Community
</h2>
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
Discover insights, tutorials, and stories from our community.
Stay updated with the latest in web development, cloud computing, and technology.
</p>
</div>
{/* Static Content Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-12">
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-blue-500/10 rounded-full">
<Code className="w-5 h-5 text-blue-600" />
</div>
<span className="text-sm text-muted-foreground">Web Development</span>
</div>
<h3 className="text-xl font-semibold mb-3">
Modern Development Best Practices
</h3>
<p className="text-muted-foreground mb-4">
Learn the latest approaches to building scalable web applications with modern frameworks and tools.
</p>
<div className="flex gap-2">
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs rounded">
React
</span>
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs rounded">
Next.js
</span>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-green-500/10 rounded-full">
<Globe className="w-5 h-5 text-green-600" />
</div>
<span className="text-sm text-muted-foreground">Cloud Computing</span>
</div>
<h3 className="text-xl font-semibold mb-3">
Scaling Applications in the Cloud
</h3>
<p className="text-muted-foreground mb-4">
Discover strategies for deploying and scaling applications using cloud infrastructure and services.
</p>
<div className="flex gap-2">
<span className="px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 text-xs rounded">
AWS
</span>
<span className="px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs rounded">
Docker
</span>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-purple-500/10 rounded-full">
<Zap className="w-5 h-5 text-purple-600" />
</div>
<span className="text-sm text-muted-foreground">Performance</span>
</div>
<h3 className="text-xl font-semibold mb-3">
Optimizing for Speed and Efficiency
</h3>
<p className="text-muted-foreground mb-4">
Learn techniques to optimize your applications for better performance and user experience.
</p>
<div className="flex gap-2">
<span className="px-2 py-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-xs rounded">
Performance
</span>
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 text-xs rounded">
Optimization
</span>
</div>
</CardContent>
</Card>
</div>
{/* Call to Action */}
<div className="text-center space-y-6">
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button size="lg" asChild>
<Link href="/blogs" className="flex items-center gap-2">
Explore All Articles
<ArrowRight className="w-4 h-4" />
</Link>
</Button>
<Button variant="outline" size="lg" asChild>
<Link href="/blogs?q=">Search Articles</Link>
</Button>
</div>
<div className="pt-8 border-t">
<p className="text-sm text-muted-foreground mb-4">
Ready to share your knowledge with the community?
</p>
<Button variant="ghost" asChild>
<Link href="/auth" className="flex items-center gap-2">
<BookOpen className="w-4 h-4" />
Start Writing Today
</Link>
</Button>
</div>
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import { useEffect, useState } from 'react'
interface StartupTestProps {
children: React.ReactNode
}
export function StartupTest({ children }: StartupTestProps) {
const [testComplete, setTestComplete] = useState(false)
const [testPassed, setTestPassed] = useState(false)
useEffect(() => {
// Run startup test
const runStartupTest = async () => {
try {
console.log('🚀 Running application startup tests...')
const response = await fetch('/api/startup-test')
const result = await response.json()
setTestPassed(result.success)
setTestComplete(true)
if (result.success) {
console.log('✅ Application startup tests passed')
} else {
console.warn('⚠️ Application startup tests failed:', result.message)
}
} catch (error) {
console.error('❌ Failed to run startup tests:', error)
setTestPassed(false)
setTestComplete(true)
}
}
runStartupTest()
}, [])
// Don't render anything special, just run the test in background
// The test results will appear in console
return <>{children}</>
}
// Optional: Export a hook for components that want to check startup status
export function useStartupStatus() {
const [status, setStatus] = useState<{
complete: boolean
passed: boolean
services?: { mongodb: boolean; redis: boolean }
}>({ complete: false, passed: false })
useEffect(() => {
const checkStatus = async () => {
try {
const response = await fetch('/api/startup-test')
const result = await response.json()
setStatus({
complete: true,
passed: result.success,
services: result.services,
})
} catch (error) {
setStatus({ complete: true, passed: false })
}
}
checkStatus()
}, [])
return status
}

View File

@@ -0,0 +1,90 @@
import { Card, CardContent } from '@/components/ui/card'
import { Star, Quote } from 'lucide-react'
const Testimonials = () => {
const testimonials = [
{
name: 'Alex Rodriguez',
role: 'Senior DevOps Engineer',
company: 'TechCorp',
content:
"SiliconPin's Kubernetes hosting has been a game-changer for our deployment pipeline. The auto-scaling works flawlessly and the support team is incredibly knowledgeable.",
rating: 5,
avatar: 'AR',
},
{
name: 'Sarah Chen',
role: 'Full Stack Developer',
company: 'StartupXYZ',
content:
"The VPS performance is outstanding and the control panel makes management so easy. I've moved all my client projects here and couldn't be happier.",
rating: 5,
avatar: 'SC',
},
{
name: 'Marcus Thompson',
role: 'CTO',
company: 'GrowthLab',
content:
'Their SMTP service has 99.9% deliverability rates. Our email campaigns have never performed better. The API integration was seamless.',
rating: 5,
avatar: 'MT',
},
{
name: 'Emily Davis',
role: 'Product Manager',
company: 'InnovateCo',
content:
"The web tools suite is incredibly useful. The speech-to-text API powers our transcription service and it's more accurate than any other solution we've tried.",
rating: 5,
avatar: 'ED',
},
]
return (
<section className="py-24 bg-muted/30">
<div className="container mx-auto px-4">
<div className="text-center mb-16">
<h2 className="text-4xl font-bold mb-4">Trusted by Developers</h2>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
See what our customers say about SiliconPin's services
</p>
</div>
<div className="grid md:grid-cols-2 gap-8">
{testimonials.map((testimonial, index) => (
<Card key={index} className="hover:shadow-card transition-shadow duration-300">
<CardContent className="p-8">
<div className="flex items-center mb-4">
{[...Array(testimonial.rating)].map((_, i) => (
<Star key={i} className="w-4 h-4 fill-warning text-warning" />
))}
</div>
<Quote className="w-8 h-8 text-primary mb-4 opacity-60" />
<p className="text-foreground mb-6 leading-relaxed italic">
"{testimonial.content}"
</p>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-gradient-hero rounded-full flex items-center justify-center text-white font-semibold">
{testimonial.avatar}
</div>
<div>
<div className="font-semibold text-foreground">{testimonial.name}</div>
<div className="text-sm text-muted-foreground">
{testimonial.role} at {testimonial.company}
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
)
}
export default Testimonials

View File

@@ -0,0 +1,188 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
LayoutDashboard,
Users,
CreditCard,
Settings,
FileText,
Menu,
X,
LogOut,
BarChart3,
Download,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface AdminLayoutProps {
children: React.ReactNode
}
const navigation = [
{
name: 'Dashboard',
href: '/admin',
icon: LayoutDashboard,
},
{
name: 'Users',
href: '/admin/users',
icon: Users,
},
{
name: 'Billing',
href: '/admin/billing',
icon: CreditCard,
},
{
name: 'Services',
href: '/admin/services',
icon: FileText,
},
{
name: 'Analytics',
href: '/admin/analytics',
icon: BarChart3,
},
{
name: 'Reports',
href: '/admin/reports',
icon: Download,
},
{
name: 'Settings',
href: '/admin/settings',
icon: Settings,
},
]
export default function AdminLayout({ children }: AdminLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false)
const pathname = usePathname()
const handleLogout = () => {
// Clear auth tokens and redirect
document.cookie = 'token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT'
localStorage.removeItem('token')
window.location.href = '/auth'
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-blue-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black bg-opacity-50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<div
className={cn(
'fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
<div className="flex h-full flex-col">
{/* Logo */}
<div className="flex h-16 items-center justify-between px-6 border-b border-gray-200 dark:border-gray-700">
<Link href="/admin" className="flex items-center space-x-2">
<div className="w-8 h-8 bg-gradient-to-r from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center shadow-lg">
<span className="text-white font-bold text-sm">SP</span>
</div>
<span className="text-xl font-bold text-gray-900 dark:text-white">Admin</span>
</Link>
<Button
variant="ghost"
size="sm"
className="lg:hidden"
onClick={() => setSidebarOpen(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-1">
{navigation.map((item) => {
const isActive =
pathname === item.href || (item.href !== '/admin' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 group',
isActive
? 'bg-blue-600 dark:bg-blue-500 text-white shadow-md'
: 'text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
)}
onClick={() => setSidebarOpen(false)}
>
<item.icon
className={cn(
'mr-3 h-5 w-5 transition-colors',
isActive
? 'text-white'
: 'text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-300'
)}
/>
{item.name}
</Link>
)
})}
</nav>
{/* Logout */}
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<Button
variant="ghost"
className="w-full justify-start text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all duration-200"
onClick={handleLogout}
>
<LogOut className="mr-3 h-5 w-5" />
Logout
</Button>
</div>
</div>
</div>
{/* Main content */}
<div className="lg:pl-64">
{/* Top bar */}
<div className="sticky top-0 z-30 bg-white/90 dark:bg-gray-900/90 backdrop-blur-md border-b border-gray-200 dark:border-gray-700 px-4 py-3 lg:px-6 shadow-sm">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="lg:hidden text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={() => setSidebarOpen(true)}
>
<Menu className="h-5 w-5" />
</Button>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
SiliconPin Admin Panel
</span>
</div>
</div>
</div>
</div>
{/* Page content */}
<main className="p-4 lg:p-6">
<div className="max-w-7xl mx-auto">{children}</div>
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,567 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Search,
Edit,
RefreshCw,
ChevronLeft,
ChevronRight,
Download,
DollarSign,
} from 'lucide-react'
import { toast } from '@/hooks/use-toast'
import { formatDistanceToNow } from 'date-fns'
interface BillingRecord {
_id: string
user_email: string
silicon_id: string
service_type: string
service_name: string
amount: number
tax_amount: number
discount_applied: number
payment_status: 'pending' | 'completed' | 'failed' | 'refunded'
service_status: 'active' | 'inactive' | 'suspended' | 'cancelled'
billing_cycle: string
created_at: string
updated_at: string
}
interface BillingSummary {
totalAmount: number
totalTax: number
totalDiscount: number
completedCount: number
pendingCount: number
failedCount: number
}
export default function BillingManagement() {
const [billings, setBillings] = useState<BillingRecord[]>([])
const [summary, setSummary] = useState<BillingSummary | null>(null)
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [serviceTypeFilter, setServiceTypeFilter] = useState('all')
const [paymentStatusFilter, setPaymentStatusFilter] = useState('all')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [editingBilling, setEditingBilling] = useState<BillingRecord | null>(null)
const [editForm, setEditForm] = useState({
payment_status: 'pending' as 'pending' | 'completed' | 'failed' | 'refunded',
service_status: 'active' as 'active' | 'inactive' | 'suspended' | 'cancelled',
amount: 0,
notes: '',
})
const fetchBillings = useCallback(async () => {
setLoading(true)
try {
const token =
localStorage.getItem('token') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('token='))
?.split('=')[1]
const params = new URLSearchParams({
page: currentPage.toString(),
limit: '20',
search,
serviceType: serviceTypeFilter,
paymentStatus: paymentStatusFilter,
...(dateFrom && { dateFrom }),
...(dateTo && { dateTo }),
})
const response = await fetch(`/api/admin/billing?${params}`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
throw new Error('Failed to fetch billing data')
}
const data = await response.json()
setBillings(data.billings)
setSummary(data.summary)
setTotalPages(data.pagination.totalPages)
} catch (error) {
console.error('Billing fetch error:', error)
toast({
title: 'Error',
description: 'Failed to load billing data',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [search, serviceTypeFilter, paymentStatusFilter, dateFrom, dateTo, currentPage])
useEffect(() => {
fetchBillings()
}, [fetchBillings])
const handleEditBilling = (billing: BillingRecord) => {
setEditingBilling(billing)
setEditForm({
payment_status: billing.payment_status,
service_status: billing.service_status,
amount: billing.amount,
notes: '',
})
}
const handleUpdateBilling = async () => {
if (!editingBilling) return
try {
const token =
localStorage.getItem('token') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('token='))
?.split('=')[1]
const response = await fetch('/api/admin/billing', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
action: 'update',
billingId: editingBilling._id,
data: editForm,
}),
})
if (!response.ok) {
throw new Error('Failed to update billing record')
}
const data = await response.json()
setBillings(billings.map((b) => (b._id === editingBilling._id ? data.billing : b)))
setEditingBilling(null)
toast({
title: 'Success',
description: 'Billing record updated successfully',
})
} catch (error) {
console.error('Billing update error:', error)
toast({
title: 'Error',
description: 'Failed to update billing record',
variant: 'destructive',
})
}
}
const handleRefund = async (billingId: string) => {
if (!confirm('Are you sure you want to process a refund for this billing record?')) return
try {
const token =
localStorage.getItem('token') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('token='))
?.split('=')[1]
const response = await fetch('/api/admin/billing', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
action: 'refund',
billingId,
data: { refundReason: 'Admin refund' },
}),
})
if (!response.ok) {
throw new Error('Failed to process refund')
}
const data = await response.json()
setBillings(billings.map((b) => (b._id === billingId ? data.billing : b)))
toast({
title: 'Success',
description: data.message,
})
} catch (error) {
console.error('Refund error:', error)
toast({
title: 'Error',
description: 'Failed to process refund',
variant: 'destructive',
})
}
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'completed':
case 'active':
return 'bg-green-100 text-green-800'
case 'pending':
return 'bg-yellow-100 text-yellow-800'
case 'failed':
case 'cancelled':
return 'bg-red-100 text-red-800'
case 'refunded':
return 'bg-purple-100 text-purple-800'
case 'inactive':
case 'suspended':
return 'bg-gray-100 text-gray-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
return (
<div className="space-y-6">
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Revenue</p>
<p className="text-2xl font-bold">{formatCurrency(summary.totalAmount)}</p>
</div>
<DollarSign className="h-8 w-8 text-green-600" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Completed</p>
<p className="text-2xl font-bold text-green-600">{summary.completedCount}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Pending</p>
<p className="text-2xl font-bold text-yellow-600">{summary.pendingCount}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Failed</p>
<p className="text-2xl font-bold text-red-600">{summary.failedCount}</p>
</div>
</div>
</CardContent>
</Card>
</div>
)}
<Card>
<CardHeader>
<CardTitle>Billing Management</CardTitle>
</CardHeader>
<CardContent>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Select value={serviceTypeFilter} onValueChange={setServiceTypeFilter}>
<SelectTrigger>
<SelectValue placeholder="Service Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Services</SelectItem>
<SelectItem value="vps_deployment">VPS</SelectItem>
<SelectItem value="kubernetes_deployment">Kubernetes</SelectItem>
<SelectItem value="developer_hire">Developer Hire</SelectItem>
<SelectItem value="vpn_service">VPN</SelectItem>
<SelectItem value="hosting_config">Hosting</SelectItem>
</SelectContent>
</Select>
<Select value={paymentStatusFilter} onValueChange={setPaymentStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Payment Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
<SelectItem value="refunded">Refunded</SelectItem>
</SelectContent>
</Select>
<Input
type="date"
placeholder="From Date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
<Input
type="date"
placeholder="To Date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
{/* Billing Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4">Customer</th>
<th className="text-left py-3 px-4">Service</th>
<th className="text-left py-3 px-4">Amount</th>
<th className="text-left py-3 px-4">Payment Status</th>
<th className="text-left py-3 px-4">Service Status</th>
<th className="text-left py-3 px-4">Date</th>
<th className="text-left py-3 px-4">Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="text-center py-8">
Loading billing data...
</td>
</tr>
) : billings.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-500">
No billing records found
</td>
</tr>
) : (
billings.map((billing) => (
<tr key={billing._id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4">
<div>
<p className="font-medium">{billing.user_email}</p>
<p className="text-sm text-gray-500">ID: {billing.silicon_id}</p>
</div>
</td>
<td className="py-3 px-4">
<div>
<p className="font-medium">{billing.service_name}</p>
<p className="text-sm text-gray-500 capitalize">
{billing.service_type.replace('_', ' ')}
</p>
</div>
</td>
<td className="py-3 px-4">
<div>
<p className="font-medium">{formatCurrency(billing.amount)}</p>
{billing.tax_amount > 0 && (
<p className="text-sm text-gray-500">
Tax: {formatCurrency(billing.tax_amount)}
</p>
)}
</div>
</td>
<td className="py-3 px-4">
<Badge className={getStatusBadgeColor(billing.payment_status)}>
{billing.payment_status}
</Badge>
</td>
<td className="py-3 px-4">
<Badge className={getStatusBadgeColor(billing.service_status)}>
{billing.service_status}
</Badge>
</td>
<td className="py-3 px-4">
<span className="text-sm text-gray-600">
{billing.created_at
? formatDistanceToNow(new Date(billing.created_at), { addSuffix: true })
: 'N/A'}
</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditBilling(billing)}
>
<Edit className="h-4 w-4" />
</Button>
{billing.payment_status === 'completed' && (
<Button
variant="outline"
size="sm"
onClick={() => handleRefund(billing._id)}
className="text-purple-600 hover:text-purple-700"
>
<RefreshCw className="h-4 w-4" />
</Button>
)}
<Button variant="outline" size="sm">
<Download className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</p>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Edit Billing Dialog */}
<Dialog open={!!editingBilling} onOpenChange={() => setEditingBilling(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Billing Record</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="payment_status">Payment Status</Label>
<Select
value={editForm.payment_status}
onValueChange={(value: 'pending' | 'completed' | 'failed' | 'refunded') =>
setEditForm({ ...editForm, payment_status: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
<SelectItem value="refunded">Refunded</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="service_status">Service Status</Label>
<Select
value={editForm.service_status}
onValueChange={(value: 'active' | 'inactive' | 'suspended' | 'cancelled') =>
setEditForm({ ...editForm, service_status: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="suspended">Suspended</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="amount">Amount</Label>
<Input
id="amount"
type="number"
value={editForm.amount}
onChange={(e) =>
setEditForm({ ...editForm, amount: parseFloat(e.target.value) || 0 })
}
/>
</div>
<div>
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={editForm.notes}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
placeholder="Add notes about this update..."
/>
</div>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setEditingBilling(null)}>
Cancel
</Button>
<Button onClick={handleUpdateBilling}>Update Record</Button>
</div>
</div>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,130 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Users, DollarSign, CreditCard, TrendingUp, UserCheck, Activity } from 'lucide-react'
interface DashboardStatsProps {
stats: {
users: {
total: number
active: number
newThisMonth: number
verified: number
verificationRate: string
}
revenue: {
total: number
monthly: number
weekly: number
}
transactions: {
total: number
monthly: number
}
services: {
total: number
active: number
}
developerRequests: {
total: number
pending: number
}
}
}
export default function DashboardStats({ stats }: DashboardStatsProps) {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const statCards = [
{
title: 'Total Users',
value: stats.users.total.toLocaleString(),
change: `+${stats.users.newThisMonth} this month`,
icon: Users,
color: 'text-blue-600',
},
{
title: 'Total Revenue',
value: formatCurrency(stats.revenue.total),
change: `${formatCurrency(stats.revenue.monthly)} this month`,
icon: DollarSign,
color: 'text-green-600',
},
{
title: 'Active Services',
value: stats.services.active.toLocaleString(),
change: `${stats.services.total} total services`,
icon: Activity,
color: 'text-purple-600',
},
{
title: 'Verified Users',
value: `${stats.users.verificationRate}%`,
change: `${stats.users.verified} verified`,
icon: UserCheck,
color: 'text-emerald-600',
},
{
title: 'Transactions',
value: stats.transactions.total.toLocaleString(),
change: `+${stats.transactions.monthly} this month`,
icon: CreditCard,
color: 'text-orange-600',
},
{
title: 'Developer Requests',
value: stats.developerRequests.total.toLocaleString(),
change: `${stats.developerRequests.pending} pending`,
icon: TrendingUp,
color: 'text-indigo-600',
},
]
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{statCards.map((card, index) => {
const gradients = [
'from-blue-500 to-blue-600',
'from-green-500 to-emerald-600',
'from-purple-500 to-indigo-600',
'from-emerald-500 to-teal-600',
'from-orange-500 to-red-500',
'from-indigo-500 to-purple-600',
]
const bgGradient = gradients[index % gradients.length]
return (
<Card
key={index}
className="border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 bg-white dark:bg-gray-800"
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-400">
{card.title}
</CardTitle>
<div className={`p-2 rounded-lg bg-gradient-to-r ${bgGradient} shadow-lg`}>
<card.icon className="h-5 w-5 text-white" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
{card.value}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center">
<span className="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
{card.change}
</p>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -0,0 +1,137 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { formatDistanceToNow } from 'date-fns'
interface RecentActivityProps {
recentUsers: Array<{
_id: string
name: string
email: string
siliconId: string
createdAt: string
}>
recentTransactions: Array<{
_id: string
type: string
amount: number
description: string
status: string
createdAt: string
userId: {
name: string
email: string
siliconId: string
}
}>
}
export default function RecentActivity({ recentUsers, recentTransactions }: RecentActivityProps) {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const getTransactionBadgeColor = (type: string) => {
switch (type) {
case 'credit':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300'
case 'debit':
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300'
default:
return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300'
}
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Users */}
<Card className="border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-white">
Recent Users
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentUsers.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">No recent users</p>
) : (
recentUsers.map((user) => (
<div
key={user._id}
className="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-b-0"
>
<div className="flex-1">
<p className="font-medium text-gray-900 dark:text-white">{user.name}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">{user.email}</p>
<p className="text-xs text-gray-500 dark:text-gray-500">ID: {user.siliconId}</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500 dark:text-gray-400">
{user.createdAt
? formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })
: 'N/A'}
</p>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
{/* Recent Transactions */}
<Card className="border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-white">
Recent Transactions
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentTransactions.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">No recent transactions</p>
) : (
recentTransactions.map((transaction) => (
<div
key={transaction._id}
className="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-b-0"
>
<div className="flex-1">
<div className="flex items-center space-x-2">
<Badge className={getTransactionBadgeColor(transaction.type)}>
{transaction.type}
</Badge>
<span className="font-medium text-gray-900 dark:text-white">
{formatCurrency(transaction.amount)}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{transaction.description}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500">
{transaction.userId?.name} ({transaction.userId?.siliconId})
</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500 dark:text-gray-400">
{transaction.createdAt
? formatDistanceToNow(new Date(transaction.createdAt), { addSuffix: true })
: 'N/A'}
</p>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,284 @@
'use client'
import { useState } from 'react'
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Download, FileText, Users, CreditCard, Activity, BarChart3 } from 'lucide-react'
import { toast } from '@/hooks/use-toast'
const reportTypes = [
{
id: 'users',
name: 'Users Report',
description: 'Export all user data including registration info, roles, and verification status',
icon: Users,
},
{
id: 'transactions',
name: 'Transactions Report',
description: 'Export all transaction data including payments, refunds, and balance changes',
icon: CreditCard,
},
{
id: 'billing',
name: 'Billing Report',
description: 'Export billing records including service purchases and payment status',
icon: FileText,
},
{
id: 'developer-requests',
name: 'Developer Requests Report',
description: 'Export developer hire requests and their status',
icon: Activity,
},
{
id: 'summary',
name: 'Summary Report',
description: 'Comprehensive overview with key metrics and statistics',
icon: BarChart3,
},
]
export default function ReportsManagement() {
const [selectedReport, setSelectedReport] = useState('')
const [format, setFormat] = useState('json')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [generating, setGenerating] = useState(false)
const handleGenerateReport = async () => {
if (!selectedReport) {
toast({
title: 'Error',
description: 'Please select a report type',
variant: 'destructive',
})
return
}
setGenerating(true)
try {
const token =
localStorage.getItem('token') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('token='))
?.split('=')[1]
const params = new URLSearchParams({
type: selectedReport,
format,
...(dateFrom && { dateFrom }),
...(dateTo && { dateTo }),
})
const response = await fetch(`/api/admin/reports?${params}`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
throw new Error('Failed to generate report')
}
if (format === 'csv') {
// Handle CSV download
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selectedReport}_report_${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} else {
// Handle JSON download
const data = await response.json()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selectedReport}_report_${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
}
toast({
title: 'Success',
description: 'Report generated and downloaded successfully',
})
} catch (error) {
console.error('Report generation error:', error)
toast({
title: 'Error',
description: 'Failed to generate report',
variant: 'destructive',
})
} finally {
setGenerating(false)
}
}
const handleQuickExport = async (reportType: string) => {
setSelectedReport(reportType)
setFormat('csv')
// Trigger immediate export
setTimeout(() => {
handleGenerateReport()
}, 100)
}
return (
<div className="space-y-6">
{/* Quick Export Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{reportTypes.map((report) => (
<Card key={report.id} className="hover:shadow-lg transition-shadow cursor-pointer">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center mb-2">
<report.icon className="h-5 w-5 text-blue-600 mr-2" />
<h3 className="font-semibold">{report.name}</h3>
</div>
<p className="text-sm text-gray-600 mb-3">{report.description}</p>
<Button
size="sm"
variant="outline"
onClick={() => handleQuickExport(report.id)}
disabled={generating}
>
<Download className="h-4 w-4 mr-2" />
Quick Export CSV
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Custom Report Generator */}
<Card>
<CardHeader>
<CardTitle>Custom Report Generator</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="reportType">Report Type</Label>
<Select value={selectedReport} onValueChange={setSelectedReport}>
<SelectTrigger>
<SelectValue placeholder="Select report type" />
</SelectTrigger>
<SelectContent>
{reportTypes.map((report) => (
<SelectItem key={report.id} value={report.id}>
{report.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="format">Format</Label>
<Select value={format} onValueChange={setFormat}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="csv">CSV</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="dateFrom">From Date</Label>
<Input
id="dateFrom"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
<div>
<Label htmlFor="dateTo">To Date</Label>
<Input
id="dateTo"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleGenerateReport} disabled={generating || !selectedReport}>
<Download className="h-4 w-4 mr-2" />
{generating ? 'Generating...' : 'Generate Report'}
</Button>
</div>
</CardContent>
</Card>
{/* Report Guidelines */}
<Card>
<CardHeader>
<CardTitle>Report Guidelines</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<h4 className="font-semibold text-gray-900">Data Privacy</h4>
<p className="text-sm text-gray-600">
Exported reports contain sensitive user data. Ensure compliance with data protection
regulations and handle all exported data securely.
</p>
</div>
<div>
<h4 className="font-semibold text-gray-900">File Formats</h4>
<ul className="text-sm text-gray-600 space-y-1">
<li>
<strong>JSON:</strong> Complete data with full structure, suitable for technical
analysis
</li>
<li>
<strong>CSV:</strong> Simplified tabular format, suitable for spreadsheet
applications
</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-gray-900">Date Filtering</h4>
<p className="text-sm text-gray-600">
Use date filters to limit report scope. Large date ranges may result in slower
generation times and larger file sizes.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,665 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Edit, X, ChevronLeft, ChevronRight, Server, Users, Activity } from 'lucide-react'
import { toast } from '@/hooks/use-toast'
import { formatDistanceToNow } from 'date-fns'
interface BillingService {
_id: string
user_email: string
silicon_id: string
service_type: string
service_name: string
amount: number
service_status: 'active' | 'inactive' | 'suspended' | 'cancelled'
created_at: string
}
interface DeveloperRequest {
_id: string
userId: {
name: string
email: string
siliconId: string
}
planType: string
projectDescription: string
status: 'pending' | 'approved' | 'in_progress' | 'completed' | 'cancelled'
assignedDeveloper?:
| {
name: string
email: string
skills: string[]
}
| string
estimatedCompletionDate?: string
createdAt: string
}
interface ServiceStats {
_id: string
count: number
revenue: number
activeServices: number
}
export default function ServiceManagement() {
const [billingServices, setBillingServices] = useState<BillingService[]>([])
const [developerRequests, setDeveloperRequests] = useState<DeveloperRequest[]>([])
const [serviceStats, setServiceStats] = useState<ServiceStats[]>([])
const [loading, setLoading] = useState(false)
const [serviceTypeFilter, setServiceTypeFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [editingService, setEditingService] = useState<any>(null)
const [editForm, setEditForm] = useState({
status: '',
assignedDeveloper: '',
estimatedCompletionDate: '',
notes: '',
})
const fetchServices = useCallback(async () => {
setLoading(true)
try {
const token =
localStorage.getItem('token') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('token='))
?.split('=')[1]
const params = new URLSearchParams({
page: currentPage.toString(),
limit: '20',
serviceType: serviceTypeFilter,
status: statusFilter,
...(dateFrom && { dateFrom }),
...(dateTo && { dateTo }),
})
const response = await fetch(`/api/admin/services?${params}`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
throw new Error('Failed to fetch services data')
}
const data = await response.json()
setBillingServices(data.billingServices)
setDeveloperRequests(data.developerRequests)
setServiceStats(data.serviceStats)
setTotalPages(data.pagination.totalPages)
} catch (error) {
console.error('Services fetch error:', error)
toast({
title: 'Error',
description: 'Failed to load services data',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [serviceTypeFilter, statusFilter, dateFrom, dateTo, currentPage])
useEffect(() => {
fetchServices()
}, [fetchServices])
const handleEditService = (service: any, type: 'billing' | 'developer') => {
setEditingService({ ...service, type })
if (type === 'billing') {
setEditForm({
status: service.service_status,
assignedDeveloper: '',
estimatedCompletionDate: '',
notes: '',
})
} else {
setEditForm({
status: service.status,
assignedDeveloper:
typeof service.assignedDeveloper === 'object' && service.assignedDeveloper?.name
? service.assignedDeveloper.name
: typeof service.assignedDeveloper === 'string'
? service.assignedDeveloper
: '',
estimatedCompletionDate: service.estimatedCompletionDate || '',
notes: '',
})
}
}
const handleUpdateService = async () => {
if (!editingService) return
try {
const token =
localStorage.getItem('token') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('token='))
?.split('=')[1]
const action = editingService.type === 'billing' ? 'updateBilling' : 'updateDeveloperRequest'
const response = await fetch('/api/admin/services', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
action,
serviceId: editingService._id,
serviceType: editingService.type,
data:
editingService.type === 'billing'
? { service_status: editForm.status }
: {
status: editForm.status,
assignedDeveloper: editForm.assignedDeveloper,
estimatedCompletionDate: editForm.estimatedCompletionDate,
notes: editForm.notes,
},
}),
})
if (!response.ok) {
throw new Error('Failed to update service')
}
const data = await response.json()
if (editingService.type === 'billing') {
setBillingServices(
billingServices.map((s) => (s._id === editingService._id ? data.service : s))
)
} else {
setDeveloperRequests(
developerRequests.map((s) => (s._id === editingService._id ? data.service : s))
)
}
setEditingService(null)
toast({
title: 'Success',
description: 'Service updated successfully',
})
} catch (error) {
console.error('Service update error:', error)
toast({
title: 'Error',
description: 'Failed to update service',
variant: 'destructive',
})
}
}
const handleCancelService = async (serviceId: string, type: 'billing' | 'developer') => {
if (!confirm('Are you sure you want to cancel this service?')) return
try {
const token =
localStorage.getItem('token') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('token='))
?.split('=')[1]
const response = await fetch('/api/admin/services', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
action: 'cancelService',
serviceId,
serviceType: type,
}),
})
if (!response.ok) {
throw new Error('Failed to cancel service')
}
const data = await response.json()
if (type === 'billing') {
setBillingServices(billingServices.map((s) => (s._id === serviceId ? data.service : s)))
} else {
setDeveloperRequests(developerRequests.map((s) => (s._id === serviceId ? data.service : s)))
}
toast({
title: 'Success',
description: 'Service cancelled successfully',
})
} catch (error) {
console.error('Service cancel error:', error)
toast({
title: 'Error',
description: 'Failed to cancel service',
variant: 'destructive',
})
}
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'active':
case 'completed':
return 'bg-green-100 text-green-800'
case 'pending':
case 'approved':
return 'bg-yellow-100 text-yellow-800'
case 'in_progress':
return 'bg-blue-100 text-blue-800'
case 'cancelled':
case 'inactive':
return 'bg-red-100 text-red-800'
case 'suspended':
return 'bg-orange-100 text-orange-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
return (
<div className="space-y-6">
{/* Service Statistics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{serviceStats.map((stat) => (
<Card key={stat._id}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 capitalize">{stat._id.replace('_', ' ')}</p>
<p className="text-2xl font-bold">{stat.count}</p>
<p className="text-sm text-green-600">
{stat.activeServices} active {formatCurrency(stat.revenue)}
</p>
</div>
<Server className="h-8 w-8 text-blue-600" />
</div>
</CardContent>
</Card>
))}
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Service Management</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Select value={serviceTypeFilter} onValueChange={setServiceTypeFilter}>
<SelectTrigger>
<SelectValue placeholder="Service Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Services</SelectItem>
<SelectItem value="vps_deployment">VPS</SelectItem>
<SelectItem value="kubernetes_deployment">Kubernetes</SelectItem>
<SelectItem value="developer_hire">Developer Hire</SelectItem>
<SelectItem value="vpn_service">VPN</SelectItem>
<SelectItem value="hosting_config">Hosting</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Input
type="date"
placeholder="From Date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
<Input
type="date"
placeholder="To Date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
{/* Billing Services */}
<div className="mb-8">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Activity className="h-5 w-5 mr-2" />
Billing Services
</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4">Customer</th>
<th className="text-left py-3 px-4">Service</th>
<th className="text-left py-3 px-4">Amount</th>
<th className="text-left py-3 px-4">Status</th>
<th className="text-left py-3 px-4">Date</th>
<th className="text-left py-3 px-4">Actions</th>
</tr>
</thead>
<tbody>
{billingServices.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-500">
No billing services found
</td>
</tr>
) : (
billingServices.map((service) => (
<tr key={service._id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4">
<div>
<p className="font-medium">{service.user_email}</p>
<p className="text-sm text-gray-500">ID: {service.silicon_id}</p>
</div>
</td>
<td className="py-3 px-4">
<div>
<p className="font-medium">{service.service_name}</p>
<p className="text-sm text-gray-500 capitalize">
{service.service_type.replace('_', ' ')}
</p>
</div>
</td>
<td className="py-3 px-4">
<span className="font-medium">{formatCurrency(service.amount)}</span>
</td>
<td className="py-3 px-4">
<Badge className={getStatusBadgeColor(service.service_status)}>
{service.service_status}
</Badge>
</td>
<td className="py-3 px-4">
<span className="text-sm text-gray-600">
{service.created_at
? formatDistanceToNow(new Date(service.created_at), {
addSuffix: true,
})
: 'N/A'}
</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditService(service, 'billing')}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleCancelService(service._id, 'billing')}
className="text-red-600 hover:text-red-700"
>
<X className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Developer Requests */}
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Users className="h-5 w-5 mr-2" />
Developer Requests
</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4">Customer</th>
<th className="text-left py-3 px-4">Plan</th>
<th className="text-left py-3 px-4">Description</th>
<th className="text-left py-3 px-4">Status</th>
<th className="text-left py-3 px-4">Developer</th>
<th className="text-left py-3 px-4">Date</th>
<th className="text-left py-3 px-4">Actions</th>
</tr>
</thead>
<tbody>
{developerRequests.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-500">
No developer requests found
</td>
</tr>
) : (
developerRequests.map((request) => (
<tr key={request._id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4">
<div>
<p className="font-medium">{request.userId.name}</p>
<p className="text-sm text-gray-500">{request.userId.email}</p>
<p className="text-xs text-gray-400">ID: {request.userId.siliconId}</p>
</div>
</td>
<td className="py-3 px-4">
<Badge variant="outline">{request.planType}</Badge>
</td>
<td className="py-3 px-4">
<p className="text-sm max-w-xs truncate">{request.projectDescription}</p>
</td>
<td className="py-3 px-4">
<Badge className={getStatusBadgeColor(request.status)}>
{request.status}
</Badge>
</td>
<td className="py-3 px-4">
<span className="text-sm">
{typeof request.assignedDeveloper === 'object' &&
request.assignedDeveloper?.name
? request.assignedDeveloper.name
: typeof request.assignedDeveloper === 'string'
? request.assignedDeveloper
: 'Unassigned'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-sm text-gray-600">
{request.createdAt
? formatDistanceToNow(new Date(request.createdAt), {
addSuffix: true,
})
: 'N/A'}
</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditService(request, 'developer')}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleCancelService(request._id, 'developer')}
className="text-red-600 hover:text-red-700"
>
<X className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</p>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Edit Service Dialog */}
<Dialog open={!!editingService} onOpenChange={() => setEditingService(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Edit {editingService?.type === 'billing' ? 'Service' : 'Developer Request'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="status">Status</Label>
<Select
value={editForm.status}
onValueChange={(value) => setEditForm({ ...editForm, status: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{editingService?.type === 'billing' ? (
<>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="suspended">Suspended</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</>
) : (
<>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
{editingService?.type === 'developer' && (
<>
<div>
<Label htmlFor="assignedDeveloper">Assigned Developer</Label>
<Input
id="assignedDeveloper"
value={editForm.assignedDeveloper}
onChange={(e) =>
setEditForm({ ...editForm, assignedDeveloper: e.target.value })
}
placeholder="Developer name or ID"
/>
</div>
<div>
<Label htmlFor="estimatedCompletionDate">Estimated Completion Date</Label>
<Input
id="estimatedCompletionDate"
type="date"
value={editForm.estimatedCompletionDate}
onChange={(e) =>
setEditForm({ ...editForm, estimatedCompletionDate: e.target.value })
}
/>
</div>
<div>
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={editForm.notes}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
placeholder="Add notes about this update..."
/>
</div>
</>
)}
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setEditingService(null)}>
Cancel
</Button>
<Button onClick={handleUpdateService}>Update Service</Button>
</div>
</div>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,592 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Settings, Save, RefreshCw, Database, Bell, Shield, Users, Activity } from 'lucide-react'
import { toast } from '@/hooks/use-toast'
import { formatDistanceToNow } from 'date-fns'
interface SystemSettingsData {
maintenanceMode: boolean
registrationEnabled: boolean
emailVerificationRequired: boolean
maxUserBalance: number
defaultUserRole: 'user' | 'admin'
systemMessage: string
paymentGatewayEnabled: boolean
developerHireEnabled: boolean
vpsDeploymentEnabled: boolean
kubernetesDeploymentEnabled: boolean
vpnServiceEnabled: boolean
lastUpdated: string
updatedBy: string
}
interface SystemStatistics {
totalUsers: number
adminUsers: number
verifiedUsers: number
unverifiedUsers: number
}
interface RecentActivity {
id: string
action: string
details: string
timestamp: string
adminName: string
}
export default function SystemSettings() {
const [settings, setSettings] = useState<SystemSettingsData | null>(null)
const [statistics, setStatistics] = useState<SystemStatistics | null>(null)
const [recentActivities, setRecentActivities] = useState<RecentActivity[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [notificationDialog, setNotificationDialog] = useState(false)
const [notificationForm, setNotificationForm] = useState({
message: '',
targetUsers: 'all',
})
useEffect(() => {
fetchSettings()
}, [])
const fetchSettings = async () => {
try {
const token =
localStorage.getItem('accessToken') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('accessToken='))
?.split('=')[1]
const response = await fetch('/api/admin/settings', {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
throw new Error('Failed to fetch settings')
}
const data = await response.json()
setSettings(data.settings)
setStatistics(data.statistics)
setRecentActivities(data.recentActivities)
} catch (error) {
console.error('Settings fetch error:', error)
toast({
title: 'Error',
description: 'Failed to load system settings',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}
const handleSaveSettings = async () => {
if (!settings) return
setSaving(true)
try {
const token =
localStorage.getItem('accessToken') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('accessToken='))
?.split('=')[1]
const response = await fetch('/api/admin/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
action: 'updateSettings',
settings,
}),
})
if (!response.ok) {
throw new Error('Failed to update settings')
}
const data = await response.json()
setSettings(data.settings)
toast({
title: 'Success',
description: data.message,
})
} catch (error) {
console.error('Settings update error:', error)
toast({
title: 'Error',
description: 'Failed to update settings',
variant: 'destructive',
})
} finally {
setSaving(false)
}
}
const handleSystemAction = async (action: string, additionalData?: any) => {
try {
const token =
localStorage.getItem('accessToken') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('accessToken='))
?.split('=')[1]
const response = await fetch('/api/admin/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
action,
...additionalData,
}),
})
if (!response.ok) {
throw new Error(`Failed to ${action}`)
}
const data = await response.json()
toast({
title: 'Success',
description: data.message,
})
if (action === 'sendSystemNotification') {
setNotificationDialog(false)
setNotificationForm({ message: '', targetUsers: 'all' })
}
} catch (error) {
console.error(`${action} error:`, error)
toast({
title: 'Error',
description: `Failed to ${action}`,
variant: 'destructive',
})
}
}
const handleSendNotification = () => {
handleSystemAction('sendSystemNotification', notificationForm)
}
if (loading) {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
if (!settings || !statistics) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-gray-500">Failed to load system settings</p>
</div>
)
}
return (
<div className="space-y-6">
{/* System Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Users</p>
<p className="text-2xl font-bold">{statistics.totalUsers}</p>
</div>
<Users className="h-8 w-8 text-blue-600" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Admin Users</p>
<p className="text-2xl font-bold">{statistics.adminUsers}</p>
</div>
<Shield className="h-8 w-8 text-purple-600" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Verified Users</p>
<p className="text-2xl font-bold">{statistics.verifiedUsers}</p>
</div>
<Activity className="h-8 w-8 text-green-600" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Unverified Users</p>
<p className="text-2xl font-bold">{statistics.unverifiedUsers}</p>
</div>
<Activity className="h-8 w-8 text-orange-600" />
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* System Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Settings className="h-5 w-5 mr-2" />
System Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* General Settings */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">General</h3>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="maintenanceMode">Maintenance Mode</Label>
<p className="text-sm text-gray-500">Disable access for non-admin users</p>
</div>
<Switch
id="maintenanceMode"
checked={settings.maintenanceMode}
onCheckedChange={(checked) =>
setSettings({ ...settings, maintenanceMode: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="registrationEnabled">Registration Enabled</Label>
<p className="text-sm text-gray-500">Allow new user registrations</p>
</div>
<Switch
id="registrationEnabled"
checked={settings.registrationEnabled}
onCheckedChange={(checked) =>
setSettings({ ...settings, registrationEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="emailVerificationRequired">Email Verification Required</Label>
<p className="text-sm text-gray-500">Require email verification for new users</p>
</div>
<Switch
id="emailVerificationRequired"
checked={settings.emailVerificationRequired}
onCheckedChange={(checked) =>
setSettings({ ...settings, emailVerificationRequired: checked })
}
/>
</div>
<div>
<Label htmlFor="maxUserBalance">Max User Balance ()</Label>
<Input
id="maxUserBalance"
type="number"
value={settings.maxUserBalance}
onChange={(e) =>
setSettings({ ...settings, maxUserBalance: parseInt(e.target.value) || 0 })
}
/>
</div>
<div>
<Label htmlFor="defaultUserRole">Default User Role</Label>
<Select
value={settings.defaultUserRole}
onValueChange={(value: 'user' | 'admin') =>
setSettings({ ...settings, defaultUserRole: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="systemMessage">System Message</Label>
<Textarea
id="systemMessage"
value={settings.systemMessage}
onChange={(e) => setSettings({ ...settings, systemMessage: e.target.value })}
placeholder="System-wide message for users..."
/>
</div>
</div>
{/* Service Settings */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Services</h3>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="paymentGatewayEnabled">Payment Gateway</Label>
<p className="text-sm text-gray-500">Enable payment processing</p>
</div>
<Switch
id="paymentGatewayEnabled"
checked={settings.paymentGatewayEnabled}
onCheckedChange={(checked) =>
setSettings({ ...settings, paymentGatewayEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="developerHireEnabled">Developer Hire Service</Label>
<p className="text-sm text-gray-500">Enable developer hiring</p>
</div>
<Switch
id="developerHireEnabled"
checked={settings.developerHireEnabled}
onCheckedChange={(checked) =>
setSettings({ ...settings, developerHireEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="vpsDeploymentEnabled">VPS Deployment</Label>
<p className="text-sm text-gray-500">Enable VPS deployments</p>
</div>
<Switch
id="vpsDeploymentEnabled"
checked={settings.vpsDeploymentEnabled}
onCheckedChange={(checked) =>
setSettings({ ...settings, vpsDeploymentEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="kubernetesDeploymentEnabled">Kubernetes Deployment</Label>
<p className="text-sm text-gray-500">Enable Kubernetes deployments</p>
</div>
<Switch
id="kubernetesDeploymentEnabled"
checked={settings.kubernetesDeploymentEnabled}
onCheckedChange={(checked) =>
setSettings({ ...settings, kubernetesDeploymentEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="vpnServiceEnabled">VPN Service</Label>
<p className="text-sm text-gray-500">Enable VPN services</p>
</div>
<Switch
id="vpnServiceEnabled"
checked={settings.vpnServiceEnabled}
onCheckedChange={(checked) =>
setSettings({ ...settings, vpnServiceEnabled: checked })
}
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveSettings} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</div>
{settings.lastUpdated && (
<div className="text-sm text-gray-500 border-t pt-4">
Last updated{' '}
{settings.lastUpdated
? formatDistanceToNow(new Date(settings.lastUpdated), { addSuffix: true })
: 'N/A'}
by {settings.updatedBy}
</div>
)}
</CardContent>
</Card>
{/* System Actions & Recent Activity */}
<div className="space-y-6">
{/* System Actions */}
<Card>
<CardHeader>
<CardTitle>System Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Button
variant="outline"
className="w-full justify-start"
onClick={() => handleSystemAction('clearCache')}
>
<RefreshCw className="h-4 w-4 mr-2" />
Clear System Cache
</Button>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => handleSystemAction('backupDatabase')}
>
<Database className="h-4 w-4 mr-2" />
Backup Database
</Button>
<Dialog open={notificationDialog} onOpenChange={setNotificationDialog}>
<DialogTrigger asChild>
<Button variant="outline" className="w-full justify-start">
<Bell className="h-4 w-4 mr-2" />
Send System Notification
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Send System Notification</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="message">Message</Label>
<Textarea
id="message"
value={notificationForm.message}
onChange={(e) =>
setNotificationForm({ ...notificationForm, message: e.target.value })
}
placeholder="Enter notification message..."
/>
</div>
<div>
<Label htmlFor="targetUsers">Target Users</Label>
<Select
value={notificationForm.targetUsers}
onValueChange={(value) =>
setNotificationForm({ ...notificationForm, targetUsers: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Users</SelectItem>
<SelectItem value="verified">Verified Users</SelectItem>
<SelectItem value="unverified">Unverified Users</SelectItem>
<SelectItem value="admins">Admin Users</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setNotificationDialog(false)}>
Cancel
</Button>
<Button onClick={handleSendNotification}>Send Notification</Button>
</div>
</div>
</DialogContent>
</Dialog>
</CardContent>
</Card>
{/* Recent Activities */}
<Card>
<CardHeader>
<CardTitle>Recent Activities</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentActivities.length === 0 ? (
<p className="text-gray-500 text-sm">No recent activities</p>
) : (
recentActivities.map((activity) => (
<div key={activity.id} className="border-b last:border-b-0 pb-3 last:pb-0">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="font-medium text-gray-900">{activity.action}</p>
<p className="text-sm text-gray-600">{activity.details}</p>
<p className="text-xs text-gray-400">
by {activity.adminName} {' '}
{activity.timestamp
? formatDistanceToNow(new Date(activity.timestamp), {
addSuffix: true,
})
: 'N/A'}
</p>
</div>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,586 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Search, Edit, Trash2, UserPlus, ChevronLeft, ChevronRight } from 'lucide-react'
import { toast } from '@/hooks/use-toast'
import { formatDistanceToNow } from 'date-fns'
interface User {
_id: string
name: string
email: string
siliconId: string
role: 'user' | 'admin'
isVerified: boolean
balance: number
lastLogin: string
createdAt: string
provider: string
}
interface UserManagementProps {
initialUsers?: User[]
}
export default function UserManagement({ initialUsers = [] }: UserManagementProps) {
const [users, setUsers] = useState<User[]>(initialUsers)
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState('all')
const [verifiedFilter, setVerifiedFilter] = useState('all')
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [showAddDialog, setShowAddDialog] = useState(false)
const [editForm, setEditForm] = useState({
name: '',
email: '',
role: 'user' as 'user' | 'admin',
isVerified: false,
balance: 0,
})
const [addForm, setAddForm] = useState({
name: '',
email: '',
password: '',
role: 'user' as 'user' | 'admin',
isVerified: false,
balance: 0,
})
const fetchUsers = useCallback(async () => {
setLoading(true)
try {
const token =
localStorage.getItem('accessToken') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('accessToken='))
?.split('=')[1]
const params = new URLSearchParams({
page: currentPage.toString(),
limit: '20',
search,
role: roleFilter,
verified: verifiedFilter,
})
const response = await fetch(`/api/admin/users?${params}`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
throw new Error('Failed to fetch users')
}
const data = await response.json()
setUsers(data.users)
setTotalPages(data.pagination.totalPages)
} catch (error) {
console.error('Users fetch error:', error)
toast({
title: 'Error',
description: 'Failed to load users',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [search, roleFilter, verifiedFilter, currentPage])
useEffect(() => {
fetchUsers()
}, [fetchUsers])
const handleEditUser = (user: User) => {
setEditingUser(user)
setEditForm({
name: user.name,
email: user.email,
role: user.role,
isVerified: user.isVerified,
balance: user.balance,
})
}
const handleUpdateUser = async () => {
if (!editingUser) return
try {
const token =
localStorage.getItem('accessToken') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('accessToken='))
?.split('=')[1]
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
action: 'update',
userId: editingUser._id,
data: editForm,
}),
})
if (!response.ok) {
throw new Error('Failed to update user')
}
const data = await response.json()
setUsers(users.map((u) => (u._id === editingUser._id ? data.user : u)))
setEditingUser(null)
toast({
title: 'Success',
description: 'User updated successfully',
})
} catch (error) {
console.error('User update error:', error)
toast({
title: 'Error',
description: 'Failed to update user',
variant: 'destructive',
})
}
}
const handleDeleteUser = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user?')) return
try {
const token =
localStorage.getItem('accessToken') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('accessToken='))
?.split('=')[1]
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
action: 'delete',
userId,
}),
})
if (!response.ok) {
throw new Error('Failed to delete user')
}
setUsers(users.filter((u) => u._id !== userId))
toast({
title: 'Success',
description: 'User deleted successfully',
})
} catch (error) {
console.error('Error deleting user:', error)
toast({
title: 'Error',
description: 'Failed to delete user',
variant: 'destructive',
})
}
}
const handleAddUser = async () => {
try {
const token =
localStorage.getItem('accessToken') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('accessToken='))
?.split('=')[1]
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
action: 'create',
data: addForm,
}),
})
if (!response.ok) {
throw new Error('Failed to create user')
}
const data = await response.json()
setUsers([data.user, ...users])
setShowAddDialog(false)
setAddForm({
name: '',
email: '',
password: '',
role: 'user',
isVerified: false,
balance: 0,
})
toast({
title: 'Success',
description: 'User created successfully',
})
} catch (error) {
console.error('Error creating user:', error)
toast({
title: 'Error',
description: 'Failed to create user',
variant: 'destructive',
})
}
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>User Management</CardTitle>
<Button onClick={() => setShowAddDialog(true)}>
<UserPlus className="h-4 w-4 mr-2" />
Add User
</Button>
</div>
</CardHeader>
<CardContent>
{/* Filters */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="w-full md:w-40">
<SelectValue placeholder="Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Roles</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<Select value={verifiedFilter} onValueChange={setVerifiedFilter}>
<SelectTrigger className="w-full md:w-40">
<SelectValue placeholder="Verified" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Users</SelectItem>
<SelectItem value="true">Verified</SelectItem>
<SelectItem value="false">Unverified</SelectItem>
</SelectContent>
</Select>
</div>
{/* Users Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4">User</th>
<th className="text-left py-3 px-4">Role</th>
<th className="text-left py-3 px-4">Status</th>
<th className="text-left py-3 px-4">Balance</th>
<th className="text-left py-3 px-4">Last Login</th>
<th className="text-left py-3 px-4">Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} className="text-center py-8">
Loading users...
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-500">
No users found
</td>
</tr>
) : (
users.map((user) => (
<tr key={user._id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4">
<div>
<p className="font-medium">{user.name}</p>
<p className="text-sm text-gray-500">{user.email}</p>
<p className="text-xs text-gray-400">ID: {user.siliconId}</p>
</div>
</td>
<td className="py-3 px-4">
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
{user.role}
</Badge>
</td>
<td className="py-3 px-4">
<div className="flex items-center space-x-2">
<Badge variant={user.isVerified ? 'default' : 'destructive'}>
{user.isVerified ? 'Verified' : 'Unverified'}
</Badge>
<Badge variant="outline">{user.provider}</Badge>
</div>
</td>
<td className="py-3 px-4">
<span className="font-medium">{formatCurrency(user.balance)}</span>
</td>
<td className="py-3 px-4">
<span className="text-sm text-gray-600">
{user.lastLogin
? formatDistanceToNow(new Date(user.lastLogin), { addSuffix: true })
: 'Never'}
</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm" onClick={() => handleEditUser(user)}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteUser(user._id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</p>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Add User Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New User</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="add-name">Name</Label>
<Input
id="add-name"
value={addForm.name}
onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
placeholder="Enter user name"
/>
</div>
<div>
<Label htmlFor="add-email">Email</Label>
<Input
id="add-email"
type="email"
value={addForm.email}
onChange={(e) => setAddForm({ ...addForm, email: e.target.value })}
placeholder="Enter email address"
/>
</div>
<div>
<Label htmlFor="add-password">Password</Label>
<Input
id="add-password"
type="password"
value={addForm.password}
onChange={(e) => setAddForm({ ...addForm, password: e.target.value })}
placeholder="Enter password"
/>
</div>
<div>
<Label htmlFor="add-role">Role</Label>
<Select
value={addForm.role}
onValueChange={(value: 'user' | 'admin') =>
setAddForm({ ...addForm, role: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="add-balance">Initial Balance ()</Label>
<Input
id="add-balance"
type="number"
value={addForm.balance}
onChange={(e) =>
setAddForm({ ...addForm, balance: parseInt(e.target.value) || 0 })
}
placeholder="0"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="add-verified"
checked={addForm.isVerified}
onCheckedChange={(checked) => setAddForm({ ...addForm, isVerified: checked })}
/>
<Label htmlFor="add-verified">Verified</Label>
</div>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
Cancel
</Button>
<Button onClick={handleAddUser}>Create User</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Edit User Dialog */}
<Dialog open={!!editingUser} onOpenChange={() => setEditingUser(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
/>
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
/>
</div>
<div>
<Label htmlFor="role">Role</Label>
<Select
value={editForm.role}
onValueChange={(value: 'user' | 'admin') =>
setEditForm({ ...editForm, role: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="balance">Balance ()</Label>
<Input
id="balance"
type="number"
value={editForm.balance}
onChange={(e) =>
setEditForm({ ...editForm, balance: parseInt(e.target.value) || 0 })
}
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="verified"
checked={editForm.isVerified}
onCheckedChange={(checked) => setEditForm({ ...editForm, isVerified: checked })}
/>
<Label htmlFor="verified">Verified</Label>
</div>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setEditingUser(null)}>
Cancel
</Button>
<Button onClick={handleUpdateUser}>Update User</Button>
</div>
</div>
</DialogContent>
</Dialog>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,47 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Github } from 'lucide-react'
interface GitHubSignInButtonProps {
className?: string
disabled?: boolean
}
export function GitHubSignInButton({ className, disabled }: GitHubSignInButtonProps) {
const [isLoading, setIsLoading] = useState(false)
const handleGitHubSignIn = async () => {
try {
setIsLoading(true)
// Get GitHub OAuth URL from our API
const response = await fetch('/api/auth/github')
const data = await response.json()
if (data.success && data.data.authURL) {
// Redirect to GitHub OAuth
window.location.href = data.data.authURL
} else {
console.error('Failed to get GitHub auth URL:', data.error)
}
} catch (error) {
console.error('GitHub sign-in error:', error)
} finally {
setIsLoading(false)
}
}
return (
<Button
type="button"
variant="outline"
onClick={handleGitHubSignIn}
disabled={disabled || isLoading}
className={className}
>
<Github className="w-4 h-4 mr-2" />
{isLoading ? 'Signing in...' : 'Continue with GitHub'}
</Button>
)
}

View File

@@ -0,0 +1,63 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
interface GoogleSignInButtonProps {
className?: string
disabled?: boolean
}
export function GoogleSignInButton({ className, disabled }: GoogleSignInButtonProps) {
const [isLoading, setIsLoading] = useState(false)
const handleGoogleSignIn = async () => {
try {
setIsLoading(true)
// Get Google OAuth URL from our API
const response = await fetch('/api/auth/google')
const data = await response.json()
if (data.success && data.data.authURL) {
// Redirect to Google OAuth
window.location.href = data.data.authURL
} else {
console.error('Failed to get Google auth URL:', data.error)
}
} catch (error) {
console.error('Google sign-in error:', error)
} finally {
setIsLoading(false)
}
}
return (
<Button
type="button"
variant="outline"
onClick={handleGoogleSignIn}
disabled={disabled || isLoading}
className={className}
>
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
{isLoading ? 'Signing in...' : 'Continue with Google'}
</Button>
)
}

View File

@@ -0,0 +1,175 @@
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { useAuth } from '@/contexts/AuthContext'
import { GoogleSignInButton } from './GoogleSignInButton'
import { GitHubSignInButton } from './GitHubSignInButton'
import { Mail, Lock, Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
const LoginSchema = z.object({
emailOrId: z.string().min(1, 'Email or Silicon ID is required'),
password: z.string().min(1, 'Password is required'),
rememberMe: z.boolean().optional(),
})
type LoginFormData = z.infer<typeof LoginSchema>
interface LoginFormProps {
onSwitchToRegister?: () => void
}
export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
const [error, setError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const { login, loading } = useAuth()
const {
register,
handleSubmit,
formState: { errors },
setValue,
getValues,
trigger,
} = useForm<LoginFormData>({
resolver: zodResolver(LoginSchema),
})
const onSubmit = async (data: LoginFormData) => {
try {
setError(null)
await login(data.emailOrId, data.password, data.rememberMe)
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
}
}
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Sign In</CardTitle>
<CardDescription>Enter your email and password to access your account</CardDescription>
</CardHeader>
<form method="post" onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="emailOrId">Email or Silicon ID</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="emailOrId"
type="text"
placeholder="your@email.com or silicon123"
className="pl-10"
{...register('emailOrId')}
disabled={loading}
/>
</div>
{errors.emailOrId && <p className="text-sm text-red-600">{errors.emailOrId.message}</p>}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link href="/forgot-password" className="text-sm text-primary hover:underline">
Forgot password?
</Link>
</div>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
className="pl-10 pr-10"
{...register('password')}
disabled={loading}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
disabled={loading}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">{showPassword ? 'Hide password' : 'Show password'}</span>
</Button>
</div>
{errors.password && <p className="text-sm text-red-600">{errors.password.message}</p>}
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="rememberMe"
checked={getValues('rememberMe') || false}
onCheckedChange={(checked) => {
setValue('rememberMe', checked as boolean)
trigger('rememberMe')
}}
/>
<Label htmlFor="rememberMe" className="text-sm font-normal">
Remember me
</Label>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4 pt-6">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing In...' : 'Sign In'}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<GoogleSignInButton disabled={loading} />
<GitHubSignInButton disabled={loading} />
</div>
</CardFooter>
</form>
<div className="text-center text-sm text-muted-foreground mt-4">
Don't have an account?{' '}
{onSwitchToRegister ? (
<button
onClick={onSwitchToRegister}
className="text-primary hover:underline cursor-pointer"
>
Create account
</button>
) : (
<Link href="/auth?mode=register" className="text-primary hover:underline">
Create account
</Link>
)}
</div>
</Card>
)
}

View File

@@ -0,0 +1,308 @@
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { useAuth } from '@/contexts/AuthContext'
import { GoogleSignInButton } from './GoogleSignInButton'
import { GitHubSignInButton } from './GitHubSignInButton'
import { User, Mail, Phone, Lock, Eye, EyeOff, Check, X } from 'lucide-react'
import Link from 'next/link'
const RegisterSchema = z
.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email format'),
phone: z.string().optional(),
password: z
.string()
.min(6, 'Password must be at least 6 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9!@#$%^&*]/, 'Password must contain at least one number or special character'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
agreeTerms: z.boolean().refine((val) => val === true, {
message: 'You must agree to the Terms of Service and Privacy Policy',
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
})
type RegisterFormData = z.infer<typeof RegisterSchema>
interface RegisterFormProps {
onSwitchToLogin?: () => void
}
export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
const [error, setError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [password, setPassword] = useState('')
const { register: registerUser, loading } = useAuth()
const {
register,
handleSubmit,
formState: { errors },
watch,
setValue,
getValues,
trigger,
} = useForm<RegisterFormData>({
resolver: zodResolver(RegisterSchema),
})
const watchPassword = watch('password', '')
// Password strength validation
const passwordRequirements = {
minLength: watchPassword.length >= 6,
hasUppercase: /[A-Z]/.test(watchPassword),
hasNumberOrSpecial: /[0-9!@#$%^&*]/.test(watchPassword),
}
const onSubmit = async (data: RegisterFormData) => {
try {
setError(null)
await registerUser(data.email, data.name, data.password)
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed')
}
}
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Create Account</CardTitle>
<CardDescription>Fill in your details to create a new account</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<div className="relative">
<User className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="name"
type="text"
placeholder="Enter your full name"
className="pl-10"
{...register('name')}
disabled={loading}
/>
</div>
{errors.name && <p className="text-sm text-red-600">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="your@email.com"
className="pl-10"
{...register('email')}
disabled={loading}
/>
</div>
{errors.email && <p className="text-sm text-red-600">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone Number (Optional)</Label>
<div className="relative">
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="phone"
type="tel"
placeholder="+1 (123) 456-7890"
className="pl-10"
{...register('phone')}
disabled={loading}
/>
</div>
{errors.phone && <p className="text-sm text-red-600">{errors.phone.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password">Create Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="At least 6 characters"
className="pl-10 pr-10"
{...register('password')}
disabled={loading}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
disabled={loading}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">{showPassword ? 'Hide password' : 'Show password'}</span>
</Button>
</div>
{/* Password strength indicators */}
{watchPassword && (
<div className="space-y-2 mt-2">
<div className="flex items-center space-x-2 text-sm">
{passwordRequirements.minLength ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<X className="h-3 w-3 text-red-500" />
)}
<span
className={passwordRequirements.minLength ? 'text-green-600' : 'text-red-500'}
>
Minimum 6 characters
</span>
</div>
<div className="flex items-center space-x-2 text-sm">
{passwordRequirements.hasUppercase ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<X className="h-3 w-3 text-red-500" />
)}
<span
className={
passwordRequirements.hasUppercase ? 'text-green-600' : 'text-red-500'
}
>
One uppercase letter
</span>
</div>
<div className="flex items-center space-x-2 text-sm">
{passwordRequirements.hasNumberOrSpecial ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<X className="h-3 w-3 text-red-500" />
)}
<span
className={
passwordRequirements.hasNumberOrSpecial ? 'text-green-600' : 'text-red-500'
}
>
One number or special character
</span>
</div>
</div>
)}
{errors.password && <p className="text-sm text-red-600">{errors.password.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
placeholder="Confirm your password"
className="pl-10 pr-10"
{...register('confirmPassword')}
disabled={loading}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
disabled={loading}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">
{showConfirmPassword ? 'Hide password' : 'Show password'}
</span>
</Button>
</div>
{errors.confirmPassword && (
<p className="text-sm text-red-600">{errors.confirmPassword.message}</p>
)}
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="agreeTerms"
checked={getValues('agreeTerms') || false}
onCheckedChange={(checked) => {
setValue('agreeTerms', checked as boolean)
trigger('agreeTerms')
}}
/>
<Label htmlFor="agreeTerms" className="text-sm font-normal">
I agree to the{' '}
<Link href="/terms" className="text-primary hover:underline">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/privacy" className="text-primary hover:underline">
Privacy Policy
</Link>
</Label>
</div>
{errors.agreeTerms && <p className="text-sm text-red-600">{errors.agreeTerms.message}</p>}
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Creating Account...' : 'Create Account'}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<GoogleSignInButton disabled={loading} />
<GitHubSignInButton disabled={loading} />
</div>
</CardFooter>
</form>
<div className="text-center text-sm text-muted-foreground mt-4">
Already have an account?{' '}
{onSwitchToLogin ? (
<button onClick={onSwitchToLogin} className="text-primary hover:underline cursor-pointer">
Sign in
</button>
) : (
<span className="text-primary">Sign in</span>
)}
</div>
</Card>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import { useEffect } from 'react'
import { useAuth } from '@/contexts/AuthContext'
import { useRouter } from 'next/navigation'
interface RequireAuthProps {
children: React.ReactNode
redirectTo?: string
}
export function RequireAuth({ children, redirectTo = '/auth' }: RequireAuthProps) {
const { user, loading, checkAuth } = useAuth()
const router = useRouter()
useEffect(() => {
// Trigger auth check when component mounts
checkAuth()
}, [checkAuth])
useEffect(() => {
// Redirect if not authenticated after loading
if (!loading && !user) {
// Store the current path as the return URL
const currentPath = window.location.pathname + window.location.search
const returnUrl = encodeURIComponent(currentPath)
router.push(`${redirectTo}?returnUrl=${returnUrl}`)
}
}, [user, loading, router, redirectTo])
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
if (!user) {
return null
}
return <>{children}</>
}

View File

@@ -0,0 +1,261 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useAuth } from '@/contexts/AuthContext'
import { Card, CardContent, CardDescription, 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Wallet, Plus, RefreshCw, CreditCard } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
interface BalanceCardProps {
showAddBalance?: boolean
className?: string
}
export function BalanceCard({ showAddBalance = true, className = '' }: BalanceCardProps) {
const { user, getBalance, addBalance, refreshBalance } = useAuth()
const { toast } = useToast()
const [balance, setBalance] = useState<number>(user?.balance || 0)
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [showAddForm, setShowAddForm] = useState(false)
const [amount, setAmount] = useState('')
const [currency, setCurrency] = useState<'INR' | 'USD'>('INR')
const [error, setError] = useState<string | null>(null)
// Update balance when user changes
useEffect(() => {
if (user?.balance !== undefined) {
setBalance(user.balance)
}
}, [user?.balance])
const handleRefreshBalance = async () => {
if (!user) return
setRefreshing(true)
setError(null)
try {
const newBalance = await getBalance()
setBalance(newBalance)
await refreshBalance() // Update user context
toast({
title: 'Balance Updated',
description: 'Your balance has been refreshed successfully.',
})
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to refresh balance'
setError(errorMessage)
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
})
} finally {
setRefreshing(false)
}
}
const handleAddBalance = async (e: React.FormEvent) => {
e.preventDefault()
const numAmount = parseFloat(amount)
if (!numAmount || numAmount <= 0) {
setError('Please enter a valid amount')
return
}
const minAmount = currency === 'INR' ? 100 : 2
const maxAmount = currency === 'INR' ? 100000 : 1500
if (numAmount < minAmount || numAmount > maxAmount) {
setError(
`Amount must be between ${currency === 'INR' ? '₹' : '$'}${minAmount} and ${currency === 'INR' ? '₹' : '$'}${maxAmount}`
)
return
}
setLoading(true)
setError(null)
try {
const result = await addBalance(numAmount, currency)
if (result.success && result.paymentUrl && result.formData) {
// Create and submit payment form
const form = document.createElement('form')
form.method = 'POST'
form.action = result.paymentUrl
form.style.display = 'none'
// Add form data
Object.entries(result.formData).forEach(([key, value]) => {
const input = document.createElement('input')
input.type = 'hidden'
input.name = key
input.value = String(value)
form.appendChild(input)
})
document.body.appendChild(form)
form.submit()
toast({
title: 'Redirecting to Payment',
description: 'You will be redirected to the payment gateway.',
})
} else {
setError(result.error || 'Failed to initiate payment')
toast({
title: 'Payment Error',
description: result.error || 'Failed to initiate payment',
variant: 'destructive',
})
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add balance'
setError(errorMessage)
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
})
} finally {
setLoading(false)
}
}
const formatBalance = (amount: number) => {
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amount)
}
if (!user) {
return null
}
return (
<Card className={`w-full max-w-md ${className}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-2">
<Wallet className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg font-medium">Balance</CardTitle>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRefreshBalance}
disabled={refreshing}
className="h-8 w-8 p-0"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<div className="text-3xl font-bold text-primary">{formatBalance(balance)}</div>
<CardDescription>Available Balance</CardDescription>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{showAddBalance && (
<div className="space-y-3">
{!showAddForm ? (
<Button onClick={() => setShowAddForm(true)} className="w-full" variant="outline">
<Plus className="h-4 w-4 mr-2" />
Add Balance
</Button>
) : (
<form onSubmit={handleAddBalance} className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<Label htmlFor="amount">Amount</Label>
<Input
id="amount"
type="number"
placeholder="Enter amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
min={currency === 'INR' ? 100 : 2}
max={currency === 'INR' ? 100000 : 1500}
step={currency === 'INR' ? 1 : 0.01}
required
/>
</div>
<div>
<Label htmlFor="currency">Currency</Label>
<Select
value={currency}
onValueChange={(value: 'INR' | 'USD') => setCurrency(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="INR">INR ()</SelectItem>
<SelectItem value="USD">USD ($)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="text-xs text-muted-foreground">
Min: {currency === 'INR' ? '₹100' : '$2'} | Max:{' '}
{currency === 'INR' ? '₹1,00,000' : '$1,500'}
</div>
<div className="flex gap-2">
<Button type="submit" disabled={loading} className="flex-1">
{loading ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<CreditCard className="h-4 w-4 mr-2" />
Pay Now
</>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setShowAddForm(false)
setAmount('')
setError(null)
}}
>
Cancel
</Button>
</div>
</form>
)}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,61 @@
'use client'
import React from 'react'
import { useAuth } from '@/contexts/AuthContext'
import { Badge } from '@/components/ui/badge'
import { Wallet } from 'lucide-react'
interface BalanceDisplayProps {
variant?: 'default' | 'compact' | 'badge'
showIcon?: boolean
className?: string
}
export function BalanceDisplay({
variant = 'default',
showIcon = true,
className = '',
}: BalanceDisplayProps) {
const { user } = useAuth()
if (!user) return null
const balance = user.balance || 0
const formatBalance = (amount: number) => {
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amount)
}
if (variant === 'badge') {
return (
<Badge variant="secondary" className={`${className}`}>
{showIcon && <Wallet className="h-3 w-3 mr-1" />}
{formatBalance(balance)}
</Badge>
)
}
if (variant === 'compact') {
return (
<div className={`flex items-center space-x-1 text-sm ${className}`}>
{showIcon && <Wallet className="h-4 w-4 text-muted-foreground" />}
<span className="font-medium">{formatBalance(balance)}</span>
</div>
)
}
return (
<div className={`flex items-center space-x-2 ${className}`}>
{showIcon && <Wallet className="h-5 w-5 text-muted-foreground" />}
<div>
<div className="text-lg font-semibold">{formatBalance(balance)}</div>
{/* <div className="text-xs text-muted-foreground">Balance</div> */}
</div>
</div>
)
}

View File

@@ -0,0 +1,278 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useAuth } from '@/contexts/AuthContext'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
History,
RefreshCw,
ArrowUpCircle,
ArrowDownCircle,
ChevronLeft,
ChevronRight,
Filter,
} from 'lucide-react'
import { formatCurrency } from '@/lib/balance-service'
interface Transaction {
_id: string
transactionId: string
type: 'debit' | 'credit'
amount: number
service: string
serviceId?: string
description?: string
status: 'pending' | 'completed' | 'failed'
previousBalance: number
newBalance: number
createdAt: string
updatedAt: string
}
interface TransactionHistoryProps {
className?: string
showFilters?: boolean
maxHeight?: string
}
export function TransactionHistory({
className = '',
showFilters = true,
maxHeight = '400px',
}: TransactionHistoryProps) {
const { user } = useAuth()
const [transactions, setTransactions] = useState<Transaction[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [typeFilter, setTypeFilter] = useState<'all' | 'debit' | 'credit'>('all')
const [serviceFilter, setServiceFilter] = useState('all')
const fetchTransactions = async (pageNum: number = 1, reset: boolean = false) => {
if (!user) return
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({
page: pageNum.toString(),
limit: '10',
type: typeFilter,
})
if (serviceFilter && serviceFilter !== 'all') {
params.append('service', serviceFilter)
}
const response = await fetch(`/api/transactions?${params}`, {
credentials: 'include',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error?.message || 'Failed to fetch transactions')
}
if (data.success) {
if (reset) {
setTransactions(data.data.transactions)
} else {
setTransactions((prev) => [...prev, ...data.data.transactions])
}
setTotalPages(data.data.pagination.totalPages)
setPage(pageNum)
} else {
throw new Error(data.error?.message || 'Failed to fetch transactions')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch transactions'
setError(errorMessage)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTransactions(1, true)
}, [user, typeFilter, serviceFilter])
const handleRefresh = () => {
setPage(1)
fetchTransactions(1, true)
}
const handleLoadMore = () => {
if (page < totalPages) {
fetchTransactions(page + 1, false)
}
}
const getTransactionIcon = (type: string) => {
return type === 'credit' ? (
<ArrowUpCircle className="h-4 w-4 text-green-600" />
) : (
<ArrowDownCircle className="h-4 w-4 text-red-600" />
)
}
const getStatusBadge = (status: string) => {
const variants = {
completed: 'default',
pending: 'secondary',
failed: 'destructive',
} as const
return (
<Badge variant={variants[status as keyof typeof variants] || 'secondary'}>{status}</Badge>
)
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-IN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
if (!user) {
return null
}
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div className="flex items-center space-x-2">
<History className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg">Transaction History</CardTitle>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={loading}
className="h-8 w-8 p-0"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</CardHeader>
{showFilters && (
<CardContent className="pb-4">
<div className="flex gap-2">
<Select
value={typeFilter}
onValueChange={(value: 'all' | 'debit' | 'credit') => setTypeFilter(value)}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="debit">Debits</SelectItem>
<SelectItem value="credit">Credits</SelectItem>
</SelectContent>
</Select>
<Select value={serviceFilter} onValueChange={setServiceFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="All Services" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Services</SelectItem>
<SelectItem value="VPS">VPS Server</SelectItem>
<SelectItem value="Kubernetes">Kubernetes</SelectItem>
<SelectItem value="Balance">Balance Top-up</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
)}
<CardContent className="pt-0">
{error && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-3 overflow-y-auto" style={{ maxHeight }}>
{transactions.length === 0 && !loading ? (
<div className="text-center py-8 text-muted-foreground">
<History className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No transactions found</p>
<p className="text-sm">Your transaction history will appear here</p>
</div>
) : (
transactions.map((transaction) => (
<div
key={transaction._id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex items-center space-x-3">
{getTransactionIcon(transaction.type)}
<div>
<div className="font-medium text-sm">
{transaction.description || transaction.service}
</div>
<div className="text-xs text-muted-foreground">
{formatDate(transaction.createdAt)}
</div>
{transaction.serviceId && (
<div className="text-xs text-muted-foreground">
ID: {transaction.serviceId}
</div>
)}
</div>
</div>
<div className="text-right">
<div
className={`font-medium ${
transaction.type === 'credit' ? 'text-green-600' : 'text-red-600'
}`}
>
{transaction.type === 'credit' ? '+' : '-'}
{formatCurrency(transaction.amount)}
</div>
{/* <div className="text-xs text-muted-foreground">Balance: {formatCurrency(transaction.newBalance)}</div> */}
<div className="mt-1">{getStatusBadge(transaction.status)}</div>
</div>
</div>
))
)}
{loading && (
<div className="text-center py-4">
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2" />
<p className="text-sm text-muted-foreground">Loading transactions...</p>
</div>
)}
{page < totalPages && !loading && (
<div className="text-center pt-4">
<Button variant="outline" onClick={handleLoadMore} disabled={loading}>
Load More
</Button>
</div>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,3 @@
export { BalanceCard } from './BalanceCard'
export { BalanceDisplay } from './BalanceDisplay'
export { TransactionHistory } from './TransactionHistory'

View File

@@ -0,0 +1,308 @@
'use client'
import React, { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/hooks/use-toast'
import { formatCurrency } from '@/lib/balance-service'
interface BillingRecord {
billing_id: string
service: string
service_type: string
amount: number
currency: string
user_email: string
silicon_id: string
status: string
payment_status: string
cycle: string
service_name?: string
total_amount: number
remarks?: string
createdAt: string
updatedAt: string
}
interface BillingStats {
totalSpent: number
activeServices: number
totalServices: number
serviceBreakdown: Record<string, { count: number; amount: number }>
}
interface BillingHistoryProps {
className?: string
}
const statusColors = {
pending: 'bg-yellow-100 text-yellow-800',
active: 'bg-green-100 text-green-800',
completed: 'bg-blue-100 text-blue-800',
cancelled: 'bg-gray-100 text-gray-800',
failed: 'bg-red-100 text-red-800',
refunded: 'bg-purple-100 text-purple-800',
}
const paymentStatusColors = {
pending: 'bg-yellow-100 text-yellow-800',
paid: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
refunded: 'bg-purple-100 text-purple-800',
}
const serviceTypeLabels = {
vps: 'VPS Server',
kubernetes: 'Kubernetes',
developer_hire: 'Developer Hire',
vpn: 'VPN Service',
hosting: 'Web Hosting',
storage: 'Cloud Storage',
database: 'Database',
ai_service: 'AI Service',
custom: 'Custom Service',
}
export default function BillingHistory({ className }: BillingHistoryProps) {
const [billings, setBillings] = useState<BillingRecord[]>([])
const [stats, setStats] = useState<BillingStats | null>(null)
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<{
serviceType?: string
status?: string
}>({})
const [pagination, setPagination] = useState({
limit: 20,
offset: 0,
total: 0,
})
const { toast } = useToast()
const fetchBillingData = async () => {
try {
setLoading(true)
const params = new URLSearchParams({
limit: pagination.limit.toString(),
offset: pagination.offset.toString(),
})
if (filter.serviceType) params.append('serviceType', filter.serviceType)
if (filter.status) params.append('status', filter.status)
const response = await fetch(`/api/billing?${params}`)
const data = await response.json()
if (data.success) {
setBillings(data.data.billings)
setStats(data.data.stats)
setPagination((prev) => ({
...prev,
total: data.data.pagination.total,
}))
} else {
throw new Error(data.error?.message || 'Failed to fetch billing data')
}
} catch (error) {
console.error('Error fetching billing data:', error)
toast({
title: 'Error',
description: 'Failed to load billing history',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchBillingData()
}, [filter, pagination.limit, pagination.offset])
const handleFilterChange = (key: string, value: string) => {
setFilter((prev) => ({
...prev,
[key]: value === 'all' ? undefined : value,
}))
setPagination((prev) => ({ ...prev, offset: 0 }))
}
const loadMore = () => {
setPagination((prev) => ({
...prev,
offset: prev.offset + prev.limit,
}))
}
if (loading && billings.length === 0) {
return (
<div className={`space-y-4 ${className}`}>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)
}
return (
<div className={`space-y-6 ${className}`}>
{/* Statistics Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Spent</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(stats.totalSpent)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Active Services</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.activeServices}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Services</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalServices}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Service Types</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{Object.keys(stats.serviceBreakdown).length}</div>
</CardContent>
</Card>
</div>
)}
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Billing History</CardTitle>
<CardDescription>View and manage your service billing records</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<Select
value={filter.serviceType || 'all'}
onValueChange={(value) => handleFilterChange('serviceType', value)}
>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="Filter by service" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Services</SelectItem>
{Object.entries(serviceTypeLabels).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filter.status || 'all'}
onValueChange={(value) => handleFilterChange('status', value)}
>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
<SelectItem value="refunded">Refunded</SelectItem>
</SelectContent>
</Select>
</div>
{/* Billing Records */}
<div className="space-y-4">
{billings.length === 0 ? (
<div className="text-center py-8 text-gray-500">No billing records found</div>
) : (
billings.map((billing) => (
<Card key={billing.billing_id} className="border-l-4 border-l-blue-500">
<CardContent className="pt-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-semibold">{billing.service}</h3>
<Badge variant="outline">
{serviceTypeLabels[
billing.service_type as keyof typeof serviceTypeLabels
] || billing.service_type}
</Badge>
</div>
<div className="text-sm text-gray-600 space-y-1">
<div>Billing ID: {billing.billing_id}</div>
<div>Cycle: {billing.cycle}</div>
{billing.remarks && <div>Remarks: {billing.remarks}</div>}
<div>Created: {new Date(billing.createdAt).toLocaleDateString()}</div>
</div>
</div>
<div className="flex flex-col sm:items-end gap-2">
<div className="text-lg font-bold">
{formatCurrency(
billing.total_amount || billing.amount,
billing.currency as 'INR' | 'USD'
)}
</div>
<div className="flex gap-2">
<Badge
className={statusColors[billing.status as keyof typeof statusColors]}
>
{billing.status}
</Badge>
<Badge
className={
paymentStatusColors[
billing.payment_status as keyof typeof paymentStatusColors
]
}
>
{billing.payment_status}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
{/* Load More Button */}
{billings.length > 0 && billings.length < pagination.total && (
<div className="flex justify-center mt-6">
<Button onClick={loadMore} disabled={loading}>
{loading ? 'Loading...' : 'Load More'}
</Button>
</div>
)}
</CardContent>
</Card>
</div>
)
}

165
components/footer.tsx Normal file
View File

@@ -0,0 +1,165 @@
'use client'
import Link from '@/components/ui/Link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Server, Mail, Github, Twitter, Linkedin } from 'lucide-react'
import { Logo } from '@/components/Logo'
export function Footer() {
const footerSections = [
{
title: 'Hosting',
links: [
{ name: 'VPS Hosting', href: '/services' },
{ name: 'Kubernetes', href: '/services' },
{ name: 'Control Panels', href: '/services' },
{ name: 'VPN Services', href: '/services' },
],
},
{
title: 'Developer Services',
links: [
{ name: 'SMTP Services', href: '/services' },
{ name: 'Database Hosting', href: '/services' },
{ name: 'API Services', href: '/services' },
{ name: 'Consulting', href: '/services' },
],
},
{
title: 'Web Tools',
links: [
{ name: 'Speech-to-Text', href: '/tools' },
{ name: 'Image Resize', href: '/tools' },
{ name: 'Text-to-Speech', href: '/tools' },
{ name: 'API Access', href: '/tools' },
],
},
{
title: 'Company',
links: [
{ name: 'About Us', href: '/about' },
{ name: 'Contact', href: '/contact' },
{ name: 'Legal Agreement', href: '/legal-agreement' },
{ name: 'Privacy Policy', href: '/privacy' },
],
},
]
const socialLinks = [
{ icon: Twitter, href: '#', label: 'Twitter' },
{ icon: Github, href: '#', label: 'GitHub' },
{ icon: Linkedin, href: '#', label: 'LinkedIn' },
]
return (
<footer className="bg-card border-t border-border">
{/* Newsletter Section */}
{/* <div className="border-b border-border">
<div className="container mx-auto px-4 py-12">
<div className="max-w-2xl mx-auto text-center">
<h3 className="text-2xl font-bold mb-4">Stay Updated</h3>
<p className="text-muted-foreground mb-6">
Get the latest updates on new features, hosting tips, and developer resources.
</p>
<div className="flex flex-col sm:flex-row gap-4 max-w-md mx-auto">
<Input type="email" placeholder="Enter your email" className="flex-1" />
<Button variant="hero">
<Mail className="w-4 h-4 mr-2" />
Subscribe
</Button>
</div>
</div>
</div>
</div> */}
{/* Main Footer */}
<div className="container mx-auto px-4 py-16">
<div className="grid md:grid-cols-2 lg:grid-cols-6 gap-8">
{/* Brand */}
<div className="lg:col-span-2">
<div className="flex items-center space-x-2 mb-6">
<div className="w-8 h-8 rounded-lg flex items-center justify-center">
<Logo size={32} />
</div>
<span className="text-xl font-bold font-heading tracking-tight">SiliconPin</span>
</div>
<p className="text-muted-foreground mb-6 leading-relaxed">
Professional web hosting and developer services platform. Built by developers, for
developers.
</p>
<div className="flex space-x-4">
{socialLinks.map((social, index) => {
const Icon = social.icon
return (
<Button
key={index}
variant="ghost"
size="icon"
className="hover:bg-primary hover:text-primary-foreground"
aria-label={social.label}
asChild
>
<Link href={social.href}>
<Icon className="w-4 h-4" />
</Link>
</Button>
)
})}
</div>
</div>
{/* Footer Links */}
{footerSections.map((section, index) => (
<div key={index}>
<h4 className="font-semibold mb-4">{section.title}</h4>
<ul className="space-y-3">
{section.links.map((link, linkIndex) => (
<li key={linkIndex}>
<Link
href={link.href}
className="text-muted-foreground hover:text-primary transition-colors duration-300"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
{/* Bottom Bar */}
<div className="border-t border-border">
<div className="container mx-auto px-4 py-6">
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<div className="text-sm text-muted-foreground">
© {new Date().getFullYear()} SiliconPin. All rights reserved.
</div>
<div className="flex space-x-6 text-sm">
<Link
href="/privacy"
className="text-muted-foreground hover:text-primary transition-colors"
>
Privacy Policy
</Link>
<Link
href="/terms"
className="text-muted-foreground hover:text-primary transition-colors"
>
Terms of Service
</Link>
<Link
href="/refund-policy"
className="text-muted-foreground hover:text-primary transition-colors"
>
Refund Policy
</Link>
</div>
</div>
</div>
</div>
</footer>
)
}

241
components/header.tsx Normal file
View File

@@ -0,0 +1,241 @@
'use client'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import Link from '@/components/ui/Link'
import {
Menu,
X,
Server,
Zap,
Users,
LogOut,
Settings,
MessageSquare,
BookOpen,
Search,
Edit,
Tag,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ThemeToggle } from '@/components/theme-toggle'
import { useAuth } from '@/contexts/AuthContext'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Logo } from '@/components/Logo'
export function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [isClient, setIsClient] = useState(false)
const { user, logout, loading } = useAuth()
// Ensure we only render auth-dependent content after hydration
useEffect(() => {
setIsClient(true)
}, [])
const navigation = [
{ name: 'Home', href: '/', icon: Server },
{ name: 'Services', href: '/services', icon: Zap },
{ name: 'Topics', href: '/topics', icon: BookOpen },
{ name: 'Tools', href: '/tools', icon: Users },
{ name: 'About', href: '/about', icon: Users },
{ name: 'Feedback', href: '/feedback', icon: MessageSquare },
]
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-gradient-glass backdrop-blur-lg border-b border-border/50">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<div className="flex items-center space-x-2">
<Logo size={32} />
<Link
href="/"
className="text-xl font-bold text-foreground font-heading tracking-tight"
>
SiliconPin
</Link>
</div>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-8">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-muted-foreground hover:text-primary transition-colors duration-300 font-medium tracking-tight"
>
{item.name}
</Link>
))}
</nav>
{/* Actions */}
<div className="flex items-center space-x-4">
{/* Theme Toggle */}
<div className="hidden md:flex">
<ThemeToggle />
</div>
{/* Auth Section */}
{!isClient || loading ? (
// Show loading state during SSR and initial load
<div className="hidden md:flex items-center space-x-2">
<div className="w-16 h-9 bg-muted animate-pulse rounded"></div>
<div className="w-20 h-9 bg-muted animate-pulse rounded"></div>
</div>
) : user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2">
{user.avatar ? (
<Image
src={user.avatar}
alt={user.name}
width={24}
height={24}
className="w-6 h-6 rounded-full object-cover"
/>
) : (
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs">
{user.name.charAt(0).toUpperCase()}
</div>
)}
{user.name}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href="/profile" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/dashboard" className="flex items-center gap-2">
<Users className="h-4 w-4" />
Dashboard
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/topics/new" className="flex items-center gap-2">
<Edit className="h-4 w-4" />
Create Topic
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/topics" className="flex items-center gap-2">
<Search className="h-4 w-4" />
Search Topics
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()} className="text-red-600">
<LogOut className="h-4 w-4 mr-2" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="hidden md:flex items-center space-x-2">
<Button variant="ghost" asChild>
<Link href="/auth">Login</Link>
</Button>
<Button variant="hero" size="sm" asChild>
<Link href="/auth">Get Started</Link>
</Button>
</div>
)}
{/* Mobile Menu Button */}
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setIsMenuOpen(!isMenuOpen)}
>
{isMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</Button>
</div>
</div>
{/* Mobile Menu */}
{isMenuOpen && (
<div className="md:hidden py-4 border-t border-border/50">
<nav className="flex flex-col space-y-4">
{navigation.map((item) => {
const Icon = item.icon
return (
<Link
key={item.name}
href={item.href}
className="flex items-center space-x-3 text-muted-foreground hover:text-primary transition-colors duration-300 p-2 rounded-lg hover:bg-accent/50"
onClick={() => setIsMenuOpen(false)}
>
<Icon className="w-4 h-4" />
<span>{item.name}</span>
</Link>
)
})}
<div className="pt-4 border-t border-border/50 flex flex-col space-y-2">
{!isClient || loading ? (
// Show loading state during SSR and initial load
<>
<div className="w-full h-9 bg-muted animate-pulse rounded"></div>
<div className="w-full h-9 bg-muted animate-pulse rounded"></div>
</>
) : user ? (
<>
<Button variant="ghost" className="justify-start" asChild>
<Link href="/profile">Profile</Link>
</Button>
<Button variant="ghost" className="justify-start" asChild>
<Link href="/dashboard">Dashboard</Link>
</Button>
<div className="border-t border-border/50 pt-2 mt-2">
<Button variant="ghost" className="justify-start" asChild>
<Link href="/topics/new" className="flex items-center gap-2">
<Edit className="h-4 w-4" />
Create Topic
</Link>
</Button>
<Button variant="ghost" className="justify-start" asChild>
<Link href="/topics" className="flex items-center gap-2">
<Search className="h-4 w-4" />
Search Topics
</Link>
</Button>
</div>
<Button
variant="ghost"
className="justify-start text-red-600"
onClick={() => logout()}
>
Sign Out
</Button>
</>
) : (
<>
<Button variant="ghost" className="justify-start" asChild>
<Link href="/auth">Login</Link>
</Button>
<Button variant="hero" size="sm" asChild>
<Link href="/auth">Get Started</Link>
</Button>
</>
)}
</div>
</nav>
</div>
)}
</div>
</header>
)
}

View File

@@ -0,0 +1,189 @@
'use client'
import Image from 'next/image'
import { useState } from 'react'
import { useAuth } from '@/contexts/AuthContext'
import { useProfileData } from '@/hooks/useProfileData'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Camera, Mail, Calendar, Shield, Copy, Check, Server, Wallet } from 'lucide-react'
import { BalanceDisplay } from '@/components/balance'
export function ProfileCard() {
const { user } = useAuth()
const { profileStats, isLoading } = useProfileData()
const [copied, setCopied] = useState(false)
if (!user) return null
// Generate Silicon ID (dummy implementation for UI testing)
const siliconId = user.siliconId || ''
// const siliconId = user.email?.split('@')[0]?.toUpperCase() || 'SILICON001'
const handleCopySiliconId = async () => {
try {
await navigator.clipboard.writeText(siliconId)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy: ', err)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const getProviderBadge = (provider: string) => {
const variants = {
local: { variant: 'secondary' as const, label: 'Email' },
google: { variant: 'default' as const, label: 'Google' },
github: { variant: 'outline' as const, label: 'GitHub' },
}
const config = variants[provider as keyof typeof variants] || variants.local
return (
<Badge variant={config.variant} className="text-xs">
{config.label}
</Badge>
)
}
return (
<Card>
<CardHeader className="text-center">
<div className="relative mx-auto">
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-2xl font-bold mb-4 mx-auto">
{user.avatar ? (
<Image
src={user.avatar}
alt={user.name}
width={96}
height={96}
className="w-24 h-24 rounded-full object-cover"
/>
) : (
user.name.charAt(0).toUpperCase()
)}
</div>
<Button
size="sm"
variant="outline"
className="absolute bottom-0 right-0 rounded-full w-8 h-8 p-0"
>
<Camera className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<h2 className="text-xl font-semibold">{user.name}</h2>
<div className="flex items-center justify-center gap-1 text-sm">
<span className="text-muted-foreground">Silicon ID:</span>
<code className="px-2 py-1 bg-muted rounded text-sm font-mono">{siliconId}</code>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={handleCopySiliconId}>
{copied ? <Check className="w-3 h-3 text-green-600" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
<div className="flex items-center justify-center gap-2">
{getProviderBadge(user.provider)}
{user.role === 'admin' && (
<Badge variant="destructive" className="text-xs">
<Shield className="w-3 h-3 mr-1" />
Admin
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-3 text-sm">
<Mail className="w-4 h-4 text-muted-foreground" />
<span className="text-muted-foreground">{user.email}</span>
</div>
<div className="flex items-center space-x-3 text-sm">
<Calendar className="w-4 h-4 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-muted-foreground">Joined {formatDate(user.createdAt)}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<Server className="w-4 h-4 text-muted-foreground" />
{isLoading ? (
<Skeleton className="h-6 w-4" />
) : (
<span className="text-lg font-semibold">{profileStats?.activeServices || 3}</span>
)}
</div>
<span className="text-xs text-muted-foreground">Active Services</span>
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
{/* <Wallet className="w-4 h-4 text-muted-foreground" /> */}
{isLoading ? (
<Skeleton className="h-6 w-16" />
) : (
<BalanceDisplay
className="text-lg font-semibold text-green-600"
variant="default"
/>
// <span className="text-lg font-semibold text-green-600">
// ₹
// {profileS'tats?.balance?.toLocaleString('en-IN', { minimumFractionDigits: 2 }) ||
// '1,250.00'}
// </span>
)}
</div>
<span className="text-xs text-muted-foreground">Balance</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-3">
<div className="text-center">
{isLoading ? (
<Skeleton className="h-6 w-8 mx-auto mb-1" />
) : (
<div className="text-lg font-semibold text-blue-600">
{profileStats?.totalTopics || 0}
</div>
)}
<span className="text-xs text-muted-foreground">Total Topics</span>
</div>
<div className="text-center">
{isLoading ? (
<Skeleton className="h-6 w-12 mx-auto mb-1" />
) : (
<div className="text-lg font-semibold text-purple-600">
{profileStats?.totalViews
? profileStats.totalViews >= 1000
? `${(profileStats.totalViews / 1000).toFixed(1)}K`
: profileStats.totalViews.toLocaleString()
: '0'}
</div>
)}
<span className="text-xs text-muted-foreground">Total Views</span>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<span className="text-sm text-muted-foreground">Account Status</span>
<Badge variant={user.isVerified ? 'default' : 'secondary'}>
{user.isVerified ? 'Verified' : 'Unverified'}
</Badge>
</div>
</CardContent>
</Card>
)
}

File diff suppressed because it is too large Load Diff

200
components/seo/SEO.tsx Normal file
View File

@@ -0,0 +1,200 @@
/**
* SEO Component System
* Provides comprehensive SEO meta tags and Open Graph support
*/
import Head from 'next/head'
export interface SEOProps {
title?: string
description?: string
canonical?: string
openGraph?: {
type?: 'website' | 'article' | 'profile'
title?: string
description?: string
image?: string
url?: string
siteName?: string
}
twitter?: {
card?: 'summary' | 'summary_large_image' | 'app' | 'player'
site?: string
creator?: string
title?: string
description?: string
image?: string
}
additionalMetaTags?: Array<{
name?: string
property?: string
content: string
}>
additionalLinkTags?: Array<{
rel: string
href: string
type?: string
sizes?: string
}>
noindex?: boolean
nofollow?: boolean
}
const defaultSEOConfig = {
title: 'NextJS Boilerplate',
description:
'A production-ready NextJS boilerplate with TypeScript, Tailwind CSS, and authentication',
openGraph: {
type: 'website' as const,
siteName: 'NextJS Boilerplate',
},
twitter: {
card: 'summary_large_image' as const,
},
}
export function SEO({
title,
description,
canonical,
openGraph,
twitter,
additionalMetaTags = [],
additionalLinkTags = [],
noindex = false,
nofollow = false,
}: SEOProps) {
const seo = {
title: title || defaultSEOConfig.title,
description: description || defaultSEOConfig.description,
openGraph: {
...defaultSEOConfig.openGraph,
...openGraph,
},
twitter: {
...defaultSEOConfig.twitter,
...twitter,
},
}
// Create robots meta content
const robotsContent = []
if (noindex) robotsContent.push('noindex')
if (nofollow) robotsContent.push('nofollow')
if (robotsContent.length === 0) robotsContent.push('index', 'follow')
return (
<Head>
{/* Basic Meta Tags */}
<title>{seo.title}</title>
<meta name="description" content={seo.description} />
<meta name="robots" content={robotsContent.join(', ')} />
{/* Canonical URL */}
{canonical && <link rel="canonical" href={canonical} />}
{/* Open Graph */}
<meta property="og:type" content={seo.openGraph.type} />
<meta property="og:title" content={seo.openGraph.title || seo.title} />
<meta property="og:description" content={seo.openGraph.description || seo.description} />
{seo.openGraph.image && <meta property="og:image" content={seo.openGraph.image} />}
{seo.openGraph.url && <meta property="og:url" content={seo.openGraph.url} />}
{seo.openGraph.siteName && <meta property="og:site_name" content={seo.openGraph.siteName} />}
{/* Twitter Card */}
<meta name="twitter:card" content={seo.twitter.card} />
{seo.twitter.site && <meta name="twitter:site" content={seo.twitter.site} />}
{seo.twitter.creator && <meta name="twitter:creator" content={seo.twitter.creator} />}
<meta name="twitter:title" content={seo.twitter.title || seo.title} />
<meta name="twitter:description" content={seo.twitter.description || seo.description} />
{seo.twitter.image && <meta name="twitter:image" content={seo.twitter.image} />}
{/* Additional Meta Tags */}
{additionalMetaTags.map((tag, index) => (
<meta
key={index}
{...(tag.name ? { name: tag.name } : { property: tag.property })}
content={tag.content}
/>
))}
{/* Additional Link Tags */}
{additionalLinkTags.map((tag, index) => (
<link key={index} {...tag} />
))}
</Head>
)
}
/**
* Article SEO component for topic posts
*/
interface ArticleSEOProps extends Omit<SEOProps, 'openGraph'> {
article: {
publishedTime?: string
modifiedTime?: string
author?: string
section?: string
tags?: string[]
}
openGraph?: SEOProps['openGraph']
}
export function ArticleSEO({ article, openGraph, ...props }: ArticleSEOProps) {
const articleOpenGraph = {
...openGraph,
type: 'article' as const,
}
const additionalMetaTags = [
...(props.additionalMetaTags || []),
...(article.publishedTime
? [
{
property: 'article:published_time',
content: article.publishedTime,
},
]
: []),
...(article.modifiedTime
? [
{
property: 'article:modified_time',
content: article.modifiedTime,
},
]
: []),
...(article.author
? [
{
property: 'article:author',
content: article.author,
},
]
: []),
...(article.section
? [
{
property: 'article:section',
content: article.section,
},
]
: []),
...(article.tags
? article.tags.map((tag) => ({
property: 'article:tag',
content: tag,
}))
: []),
]
return <SEO {...props} openGraph={articleOpenGraph} additionalMetaTags={additionalMetaTags} />
}
/**
* Hook to generate SEO-friendly URLs
*/
export function useSEOUrl(baseUrl: string, path?: string) {
const cleanPath = path?.startsWith('/') ? path : `/${path || ''}`
return `${baseUrl}${cleanPath}`
}

View File

@@ -0,0 +1,70 @@
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'dark' | 'light' | 'system'
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({ children, defaultTheme = 'system' }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(defaultTheme)
const [mounted, setMounted] = useState(false)
// Only access localStorage after component has mounted on the client
useEffect(() => {
setMounted(true)
const storedTheme = localStorage.getItem('theme') as Theme
if (storedTheme) {
setTheme(storedTheme)
}
}, [])
useEffect(() => {
if (!mounted) return
const root = window.document.documentElement
root.classList.remove('light', 'dark')
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme, mounted])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem('theme', theme)
setTheme(theme)
},
}
return <ThemeProviderContext.Provider value={value}>{children}</ThemeProviderContext.Provider>
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}

View File

@@ -0,0 +1,54 @@
'use client'
import { Moon, Sun } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTheme } from '@/components/theme-provider'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return (
<Button variant="ghost" size="icon" disabled>
<Sun className="h-5 w-5" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}
const toggleTheme = () => {
if (theme === 'system') {
// If system, first switch to light/dark based on current system preference
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setTheme(isDark ? 'light' : 'dark')
} else {
setTheme(theme === 'light' ? 'dark' : 'light')
}
}
const getIcon = () => {
if (theme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
return isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />
}
return theme === 'light' ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />
}
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
>
{getIcon()}
<span className="sr-only">Toggle theme</span>
</Button>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,302 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/hooks/use-toast'
import { Volume2, Play, Square, Download, Settings, Loader2 } from 'lucide-react'
interface TtsState {
isPlaying: boolean
isProcessing: boolean
audioBlob: Blob | null
audioUrl: string | null
}
interface VoiceOption {
id: string
name: string
language: string
}
const VOICE_OPTIONS: VoiceOption[] = [
{ id: 'en-US', name: 'English (US)', language: 'en-US' },
{ id: 'en-GB', name: 'English (UK)', language: 'en-GB' },
{ id: 'es-ES', name: 'Spanish', language: 'es-ES' },
{ id: 'fr-FR', name: 'French', language: 'fr-FR' },
{ id: 'de-DE', name: 'German', language: 'de-DE' },
]
const TTS_ENDPOINT = 'https://tts41-nhdtuisbdhcvdth.siliconpin.com/tts'
export function TextToSpeechClient() {
const { toast } = useToast()
const [ttsState, setTtsState] = useState<TtsState>({
isPlaying: false,
isProcessing: false,
audioBlob: null,
audioUrl: null,
})
const [text, setText] = useState('')
const [selectedVoice, setSelectedVoice] = useState('en-US')
const [speechRate, setSpeechRate] = useState(1.0)
const [pitch, setPitch] = useState(1.0)
const audioRef = useRef<HTMLAudioElement | null>(null)
// Clean up on unmount
useEffect(() => {
return () => {
if (ttsState.audioUrl) {
URL.revokeObjectURL(ttsState.audioUrl)
}
}
}, [ttsState.audioUrl])
const convertTextToSpeech = async () => {
if (!text.trim()) {
toast({
title: 'No Text',
description: 'Please enter some text to convert to speech',
variant: 'destructive',
})
return
}
setTtsState((prev) => ({ ...prev, isProcessing: true }))
try {
const response = await fetch(TTS_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: text.trim(),
voice: selectedVoice,
rate: speechRate,
pitch: pitch,
}),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`TTS API Error (${response.status}): ${errorText}`)
}
const audioBlob = await response.blob()
const audioUrl = URL.createObjectURL(audioBlob)
setTtsState({
isPlaying: false,
isProcessing: false,
audioBlob,
audioUrl,
})
toast({
title: 'Success',
description: 'Text converted to speech successfully!',
})
} catch (error) {
const message = error instanceof Error ? error.message : 'TTS conversion failed'
toast({
title: 'Conversion Failed',
description: message,
variant: 'destructive',
})
setTtsState((prev) => ({ ...prev, isProcessing: false }))
}
}
const playAudio = () => {
if (ttsState.audioUrl) {
if (audioRef.current) {
audioRef.current.pause()
}
const audio = new Audio(ttsState.audioUrl)
audioRef.current = audio
audio.play()
setTtsState((prev) => ({ ...prev, isPlaying: true }))
audio.onended = () => {
setTtsState((prev) => ({ ...prev, isPlaying: false }))
}
audio.onerror = () => {
toast({
title: 'Playback Error',
description: 'Failed to play audio',
variant: 'destructive',
})
setTtsState((prev) => ({ ...prev, isPlaying: false }))
}
}
}
const stopAudio = () => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
setTtsState((prev) => ({ ...prev, isPlaying: false }))
}
const downloadAudio = () => {
if (ttsState.audioBlob) {
const url = URL.createObjectURL(ttsState.audioBlob)
const a = document.createElement('a')
a.href = url
a.download = `speech-${Date.now()}.wav`
a.click()
URL.revokeObjectURL(url)
}
}
const hasAudio = ttsState.audioBlob !== null
const canConvert = text.trim().length > 0 && !ttsState.isProcessing
return (
<>
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2">Text to Speech Converter</h1>
<p className="text-muted-foreground">Convert text to natural sounding speech</p>
</div>
{/* Text Input Section */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Enter Text</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter text to convert to speech..."
className="min-h-32 resize-none"
/>
<div className="flex justify-between items-center mt-4">
<span className="text-sm text-muted-foreground">{text.length} characters</span>
<Button onClick={convertTextToSpeech} disabled={!canConvert}>
{ttsState.isProcessing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Volume2 className="w-4 h-4 mr-2" />
)}
{ttsState.isProcessing ? 'Processing...' : 'Convert to Speech'}
</Button>
</div>
</CardContent>
</Card>
{/* Voice Settings */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center">
<Settings className="w-5 h-5 mr-2" />
Voice Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="voice">Voice</Label>
<Select value={selectedVoice} onValueChange={setSelectedVoice}>
<SelectTrigger>
<SelectValue placeholder="Select a voice" />
</SelectTrigger>
<SelectContent>
{VOICE_OPTIONS.map((voice) => (
<SelectItem key={voice.id} value={voice.id}>
{voice.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rate">Speech Rate: {speechRate.toFixed(1)}x</Label>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">0.5x</span>
<input
id="rate"
type="range"
min="0.5"
max="2.0"
step="0.1"
value={speechRate}
onChange={(e) => setSpeechRate(parseFloat(e.target.value))}
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
<span className="text-sm text-muted-foreground">2.0x</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="pitch">Pitch: {pitch.toFixed(1)}</Label>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">0.5</span>
<input
id="pitch"
type="range"
min="0.5"
max="2.0"
step="0.1"
value={pitch}
onChange={(e) => setPitch(parseFloat(e.target.value))}
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
<span className="text-sm text-muted-foreground">2.0</span>
</div>
</div>
</CardContent>
</Card>
{/* Audio Output */}
{hasAudio && (
<Card className="mb-6">
<CardHeader>
<CardTitle>Generated Speech</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-3">
<Button
onClick={ttsState.isPlaying ? stopAudio : playAudio}
variant={ttsState.isPlaying ? 'secondary' : 'default'}
size="lg"
>
{ttsState.isPlaying ? (
<>
<Square className="w-5 h-5 mr-2" />
Stop Playing
</>
) : (
<>
<Play className="w-5 h-5 mr-2" />
Play Audio
</>
)}
</Button>
<Button onClick={downloadAudio} variant="outline" size="lg">
<Download className="w-5 h-5 mr-2" />
Download
</Button>
</div>
</CardContent>
</Card>
)}
</>
)
}

View File

@@ -0,0 +1,276 @@
'use client'
import React, { useState, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Upload, Image as ImageIcon, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import Image from 'next/image'
import { useToast } from '@/hooks/use-toast'
interface ImageUploadProps {
value?: string | File
onChange: (value: File | null) => void
className?: string
label?: string
disabled?: boolean
aspectRatio?: 'video' | 'square' | 'auto'
currentImage?: string
}
const ImageUpload: React.FC<ImageUploadProps> = ({
value,
onChange,
className,
label = 'Upload Cover Image',
disabled = false,
aspectRatio = 'video',
}) => {
const [isUploading, setIsUploading] = useState(false)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const { toast } = useToast()
// Create preview URL when value changes
React.useEffect(() => {
if (value instanceof File) {
const url = URL.createObjectURL(value)
setPreviewUrl(url)
return () => URL.revokeObjectURL(url)
} else if (typeof value === 'string') {
setPreviewUrl(value)
} else {
setPreviewUrl(null)
}
}, [value])
const validateFile = React.useCallback(
(file: File): boolean => {
// Check file type
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select an image file (JPEG, PNG, GIF, WebP)',
variant: 'destructive',
})
return false
}
// Check file size (5MB limit)
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
toast({
title: 'File too large',
description: 'Please select an image smaller than 5MB',
variant: 'destructive',
})
return false
}
return true
},
[toast]
)
const handleFile = useCallback(
async (file: File) => {
if (!validateFile(file)) return
try {
setIsUploading(true)
onChange(file)
toast({
title: 'Image selected',
description: 'Cover image has been updated',
})
} catch (error) {
console.error('Failed to handle file:', error)
toast({
title: 'Upload failed',
description: 'Failed to process the image',
variant: 'destructive',
})
} finally {
setIsUploading(false)
}
},
[onChange, toast, validateFile]
)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
await handleFile(file)
// Clear the input value to allow uploading the same file again
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
fileInputRef.current?.click()
}
}
const handleRemove = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onChange(null)
setPreviewUrl(null)
toast({
title: 'Image removed',
description: 'Cover image has been removed',
})
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setIsDragging(true)
}
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (disabled) return
const files = Array.from(e.dataTransfer.files)
const file = files[0]
if (file) {
await handleFile(file)
}
}
const getAspectRatioClass = () => {
switch (aspectRatio) {
case 'video':
return 'aspect-video'
case 'square':
return 'aspect-square'
default:
return 'min-h-[200px]'
}
}
return (
<div className={cn('space-y-4', className)}>
<input
type="file"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
disabled={disabled}
/>
{previewUrl ? (
<div
className={cn(
'relative overflow-hidden rounded-lg bg-muted border-2 border-dashed border-transparent',
getAspectRatioClass()
)}
>
<Image
src={previewUrl}
alt="Cover image preview"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
<div className="absolute inset-0 bg-black/0 hover:bg-black/10 transition-colors">
<div className="absolute top-2 right-2 flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleClick}
disabled={disabled || isUploading}
className="bg-white/90 hover:bg-white"
>
Change
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleRemove}
disabled={disabled || isUploading}
className="bg-red-500/90 hover:bg-red-500"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
</div>
) : (
<div
className={cn(
'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-all duration-200',
getAspectRatioClass(),
isDragging
? 'border-primary bg-primary/5 scale-[1.02]'
: 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50',
disabled && 'cursor-not-allowed opacity-50'
)}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick(e as unknown as React.MouseEvent)
}
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={`${label} - click to browse or drag and drop`}
>
{isUploading ? (
<div className="flex flex-col items-center justify-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<p className="text-sm font-medium">Uploading image...</p>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-3">
<div className="rounded-full bg-muted p-4">
{isDragging ? (
<Upload className="w-6 h-6 text-primary" />
) : (
<ImageIcon className="w-6 h-6 text-muted-foreground" />
)}
</div>
<div className="text-center">
<p className="font-medium text-foreground">{label}</p>
<p className="text-sm text-muted-foreground mt-1">
{isDragging ? 'Drop your image here' : 'Click to browse or drag and drop'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Supports JPEG, PNG, GIF, WebP (max 5MB)
</p>
</div>
</div>
)}
</div>
)}
</div>
)
}
export default ImageUpload

View File

@@ -0,0 +1,273 @@
'use client'
import React, { useState } from 'react'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Check, ChevronsUpDown, Plus, X, Tag as TagIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useToast } from '@/hooks/use-toast'
export interface Tag {
id?: string
name: string
}
interface SearchableTagSelectProps {
availableTags: Tag[]
selectedTags: Tag[]
onChange: (tags: Tag[]) => void
onCreateTag?: (tagName: string) => void
className?: string
placeholder?: string
disabled?: boolean
maxTags?: number
}
const SearchableTagSelect: React.FC<SearchableTagSelectProps> = ({
availableTags,
selectedTags,
onChange,
onCreateTag,
className,
placeholder = "Add tags...",
disabled = false,
maxTags,
}) => {
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const { toast } = useToast()
// Filter tags that are not already selected and match the search input
const filteredTags = availableTags.filter(
(tag) =>
!selectedTags.some((selectedTag) =>
selectedTag.name.toLowerCase() === tag.name.toLowerCase()
) &&
tag.name.toLowerCase().includes(inputValue.toLowerCase())
)
// Handle tag selection
const selectTag = (tag: Tag) => {
if (maxTags && selectedTags.length >= maxTags) {
toast({
title: 'Tag limit reached',
description: `You can only add up to ${maxTags} tags`,
variant: 'destructive',
})
return
}
onChange([...selectedTags, tag])
setInputValue('')
setOpen(false)
toast({
title: 'Tag added',
description: `"${tag.name}" has been added to your tags`,
})
}
// Handle tag removal
const removeTag = (tagToRemove: Tag) => {
onChange(selectedTags.filter((tag) => tag.name !== tagToRemove.name))
toast({
title: 'Tag removed',
description: `"${tagToRemove.name}" has been removed`,
})
}
// Handle creating a new tag
const handleCreateTag = () => {
const trimmedInput = inputValue.trim()
if (!trimmedInput) return
// Check if tag already exists (case-insensitive)
const existsInAvailable = availableTags.some(
(tag) => tag.name.toLowerCase() === trimmedInput.toLowerCase()
)
const existsInSelected = selectedTags.some(
(tag) => tag.name.toLowerCase() === trimmedInput.toLowerCase()
)
if (existsInSelected) {
toast({
title: 'Tag already selected',
description: `"${trimmedInput}" is already in your selected tags`,
variant: 'destructive',
})
return
}
if (maxTags && selectedTags.length >= maxTags) {
toast({
title: 'Tag limit reached',
description: `You can only add up to ${maxTags} tags`,
variant: 'destructive',
})
return
}
if (existsInAvailable) {
// Tag exists in available tags, just select it
const existingTag = availableTags.find(
(tag) => tag.name.toLowerCase() === trimmedInput.toLowerCase()
)
if (existingTag) {
selectTag(existingTag)
}
} else {
// Create new tag
const newTag: Tag = {
id: `temp-${Date.now()}`,
name: trimmedInput,
}
if (onCreateTag) {
onCreateTag(trimmedInput)
}
onChange([...selectedTags, newTag])
setInputValue('')
setOpen(false)
toast({
title: 'New tag created',
description: `"${trimmedInput}" has been created and added`,
})
}
}
const hasExactMatch = filteredTags.some(
(tag) => tag.name.toLowerCase() === inputValue.toLowerCase()
)
const canCreateNewTag = inputValue.trim() !== '' && !hasExactMatch &&
(!maxTags || selectedTags.length < maxTags)
return (
<div className={cn('space-y-3', className)}>
{/* Selected Tags Display */}
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedTags.map((tag, index) => (
<Badge
key={tag.id || `${tag.name}-${index}`}
variant="secondary"
className="flex items-center gap-1 pr-1 pl-3 py-1 text-sm"
>
<TagIcon className="w-3 h-3" />
{tag.name}
<Button
type="button"
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground ml-1"
onClick={() => removeTag(tag)}
disabled={disabled}
aria-label={`Remove ${tag.name} tag`}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
</div>
)}
{/* Tag Selection Dropdown */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled || (maxTags ? selectedTags.length >= maxTags : false)}
>
<div className="flex items-center gap-2">
<TagIcon className="w-4 h-4" />
{placeholder}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder="Search or create tags..."
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList>
<CommandEmpty className="p-2">
{canCreateNewTag ? (
<Button
variant="ghost"
className="flex w-full items-center justify-start px-2 py-2 text-sm"
onClick={handleCreateTag}
>
<Plus className="mr-2 h-4 w-4" />
Create "{inputValue.trim()}"
</Button>
) : inputValue.trim() === '' ? (
<div className="text-center text-muted-foreground py-2">
Start typing to search or create tags...
</div>
) : (
<div className="text-center text-muted-foreground py-2">
{maxTags && selectedTags.length >= maxTags
? `Maximum ${maxTags} tags allowed`
: 'No tags found'
}
</div>
)}
</CommandEmpty>
<CommandGroup>
{filteredTags.map((tag, index) => (
<CommandItem
key={tag.id || `${tag.name}-${index}`}
value={tag.name}
onSelect={() => selectTag(tag)}
className="cursor-pointer"
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTags.some((selectedTag) =>
selectedTag.name.toLowerCase() === tag.name.toLowerCase()
)
? 'opacity-100'
: 'opacity-0'
)}
/>
<TagIcon className="mr-2 h-4 w-4" />
{tag.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Tag counter and limit display */}
{maxTags && (
<div className="text-xs text-muted-foreground text-right">
{selectedTags.length} / {maxTags} tags
</div>
)}
</div>
)
}
export default SearchableTagSelect

View File

@@ -0,0 +1,130 @@
'use client'
import { Button } from '@/components/ui/button'
import { useState } from 'react'
interface SimpleShareButtonsProps {
title: string
url: string
}
export function SimpleShareButtons({ title, url }: SimpleShareButtonsProps) {
const [copied, setCopied] = useState(false)
// Get full URL
const fullUrl = typeof window !== 'undefined' ? window.location.origin + url : url
const handleShare = (platform: string) => {
let shareUrl = ''
switch (platform) {
case 'twitter':
shareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(fullUrl)}`
break
case 'facebook':
shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(fullUrl)}`
break
case 'linkedin':
shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(fullUrl)}&title=${encodeURIComponent(title)}`
break
case 'whatsapp':
shareUrl = `https://wa.me/?text=${encodeURIComponent(title)} ${encodeURIComponent(fullUrl)}`
break
}
if (shareUrl) {
window.open(shareUrl, '_blank', 'width=600,height=400')
}
}
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(fullUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error('Failed to copy:', error)
}
}
return (
<div className="mt-6 pt-4 border-t">
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Share:</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 px-3 hover:bg-blue-50 hover:text-blue-600"
onClick={() => handleShare('twitter')}
>
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
Twitter
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-3 hover:bg-blue-50 hover:text-blue-700"
onClick={() => handleShare('facebook')}
>
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
Facebook
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-3 hover:bg-blue-50 hover:text-blue-800"
onClick={() => handleShare('linkedin')}
>
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
LinkedIn
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-3 hover:bg-green-50 hover:text-green-600"
onClick={() => handleShare('whatsapp')}
>
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.567-.01-.197 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.890-5.335 11.893-11.893A11.821 11.821 0 0020.885 3.488"/>
</svg>
WhatsApp
</Button>
<Button
variant="ghost"
size="sm"
className={`h-8 px-3 transition-colors ${
copied
? 'bg-green-50 text-green-700 hover:bg-green-50 hover:text-green-700'
: 'hover:bg-gray-50 hover:text-gray-700'
}`}
onClick={handleCopyLink}
disabled={copied}
>
{copied ? (
<>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copied
</>
) : (
<>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
Copy
</>
)}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,101 @@
import Image from 'next/image'
import Link from 'next/link'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Calendar, Clock, User } from 'lucide-react'
import { ITopic } from '@/models/topic'
interface TopicCardProps {
topic: ITopic
showAuthor?: boolean
showReadingTime?: boolean
}
export function TopicCard({ topic, showAuthor = true, showReadingTime = true }: TopicCardProps) {
const publishedDate = new Date(topic.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
return (
<Link href={`/topics/${topic.slug}`} className="group block">
<Card className="overflow-hidden transition-all duration-300 hover:shadow-lg hover:scale-[1.02]">
<div className="aspect-video relative overflow-hidden">
<Image
src={topic.coverImage}
alt={topic.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
<CardContent className="p-6">
{/* Metadata */}
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
<time dateTime={new Date(topic.publishedAt).toISOString()}>
{publishedDate}
</time>
</div>
{showReadingTime && topic.readingTime && (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{topic.readingTime.text}</span>
</div>
)}
{showAuthor && (
<div className="flex items-center gap-1">
<User className="w-3 h-3" />
<span>{topic.author}</span>
</div>
)}
</div>
{/* Title */}
<h2 className="text-xl font-bold mb-3 line-clamp-2 transition-colors group-hover:text-primary">
{topic.title}
</h2>
{/* Excerpt */}
<p className="text-muted-foreground mb-4 line-clamp-3 leading-relaxed">
{topic.excerpt}
</p>
{/* Tags */}
<div className="flex flex-wrap gap-2">
{topic.tags.slice(0, 3).map((tag) => (
<Badge
key={tag.id || tag.name}
variant="secondary"
className="text-xs hover:bg-primary hover:text-primary-foreground transition-colors"
>
{tag.name}
</Badge>
))}
{topic.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{topic.tags.length - 3} more
</Badge>
)}
</div>
{/* Featured indicator */}
{topic.featured && (
<div className="mt-3">
<Badge className="text-xs bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600">
Featured
</Badge>
</div>
)}
</CardContent>
</Card>
</Link>
)
}
export default TopicCard

View File

@@ -0,0 +1,7 @@
'use client'
import { ViewTracker } from './ViewTracker'
// ViewTracker component - only handles view tracking
export function TopicViewTracker({ topicSlug, isDraft }: { topicSlug: string; isDraft: boolean }) {
return <ViewTracker topicSlug={topicSlug} enabled={!isDraft} />
}

View File

@@ -0,0 +1,53 @@
'use client'
import { BlockNoteView } from '@blocknote/mantine'
import { useCreateBlockNote } from '@blocknote/react'
import { PartialBlock } from '@blocknote/core'
import '@blocknote/mantine/style.css'
import { useMemo } from 'react'
interface TopicContentProps {
content: string
contentRTE?: unknown
className?: string
}
export function TopicContent({ content, contentRTE, className = '' }: TopicContentProps) {
// Create an editor instance with initial content
const editor = useCreateBlockNote({
initialContent:
contentRTE && Array.isArray(contentRTE) && contentRTE.length > 0
? (contentRTE as PartialBlock[])
: ([{ type: 'paragraph', content: '' }] as PartialBlock[]),
})
// If we have rich text content, use BlockNote viewer
if (contentRTE && Array.isArray(contentRTE) && contentRTE.length > 0) {
return (
<div className={`topic-content ${className}`}>
<BlockNoteView editor={editor} editable={false} theme="light" />
</div>
)
}
// Fallback to rendering plain HTML content
return (
<div
className={`topic-content prose prose-lg max-w-none
prose-headings:font-bold prose-headings:text-foreground
prose-p:text-muted-foreground prose-p:leading-relaxed
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-strong:text-foreground prose-strong:font-semibold
prose-code:bg-muted prose-code:px-2 prose-code:py-1 prose-code:rounded
prose-pre:bg-muted prose-pre:border prose-pre:rounded-lg
prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:bg-muted/50
prose-ul:text-muted-foreground prose-ol:text-muted-foreground
prose-li:text-muted-foreground
prose-img:rounded-lg prose-img:shadow-lg
${className}`}
dangerouslySetInnerHTML={{ __html: content }}
/>
)
}
export default TopicContent

View File

@@ -0,0 +1,34 @@
'use client'
import { useAuth } from '@/contexts/AuthContext'
import { Button } from '@/components/ui/button'
import { Edit3 } from 'lucide-react'
import Link from 'next/link'
interface TopicEditButtonProps {
topicId: string
authorId: string
className?: string
}
export function TopicEditButton({ topicId, authorId, className }: TopicEditButtonProps) {
const { user } = useAuth()
// Only show edit button if user is authenticated and is the author
if (!user || user.id !== authorId) {
return null
}
return (
<Button
asChild
variant="outline"
size="sm"
className={className}
>
<Link href={`/topics/edit/${topicId}`} className="flex items-center gap-2">
<Edit3 className="w-4 h-4" />
Edit Post
</Link>
</Button>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { useEffect, useRef } from 'react'
interface ViewTrackerProps {
topicSlug: string
enabled?: boolean // Allow disabling for drafts or other cases
}
export function ViewTracker({ topicSlug, enabled = true }: ViewTrackerProps) {
const hasTracked = useRef(false)
useEffect(() => {
if (!enabled || hasTracked.current || !topicSlug) {
return
}
const trackView = async () => {
try {
await fetch(`/api/topics/${encodeURIComponent(topicSlug)}/view`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
hasTracked.current = true
} catch (error) {
console.warn('Failed to track topic view:', error)
}
}
// Track view after a short delay to ensure the user actually viewed the post
const timer = setTimeout(trackView, 2000)
return () => {
clearTimeout(timer)
}
}, [topicSlug, enabled])
// This component doesn't render anything
return null
}

22
components/ui/Link.tsx Normal file
View File

@@ -0,0 +1,22 @@
import Link from 'next/link'
import type { LinkProps } from 'next/link'
import { AnchorHTMLAttributes } from 'react'
const CustomLink = ({ href, ...rest }: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement>) => {
const isInternalLink = href && href.startsWith('/')
const isAnchorLink = href && href.startsWith('#')
if (isInternalLink) {
return <Link className="break-words" href={href} {...rest} />
}
if (isAnchorLink) {
return <a className="break-words" href={href} {...rest} />
}
return (
<a className="break-words" target="_blank" rel="noopener noreferrer" href={href} {...rest} />
)
}
export default CustomLink

View File

@@ -0,0 +1,52 @@
import * as React from 'react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn('border-b', className)} {...props} />
))
AccordionItem.displayName = 'AccordionItem'
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

49
components/ui/alert.tsx Normal file
View File

@@ -0,0 +1,49 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
))
Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
)
)
AlertTitle.displayName = 'AlertTitle'
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }

45
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,45 @@
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

33
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,33 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,101 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { ChevronRight, MoreHorizontal } from 'lucide-react'
import { cn } from '@/lib/utils'
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = 'Breadcrumb'
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<'ol'>>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className
)}
{...props}
/>
)
)
BreadcrumbList.displayName = 'BreadcrumbList'
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<'li'>>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn('inline-flex items-center gap-1.5', className)} {...props} />
)
)
BreadcrumbItem.displayName = 'BreadcrumbItem'
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a'
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = 'BreadcrumbLink'
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<'span'>>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-foreground', className)}
{...props}
/>
)
)
BreadcrumbPage.displayName = 'BreadcrumbPage'
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator'
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis'
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

65
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,65 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground hover:bg-primary-hover shadow-primary hover:shadow-glow',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary-hover shadow-secondary',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
hero: 'bg-gradient-hero text-primary-foreground hover:scale-105 shadow-glow font-semibold',
glass: 'bg-gradient-glass backdrop-blur-sm border border-border/50 hover:bg-accent/50',
success: 'bg-success text-success-foreground hover:bg-success/90',
premium:
'bg-gradient-secondary text-secondary-foreground hover:scale-105 shadow-secondary font-semibold',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
xl: 'h-14 rounded-lg px-10 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ComponentProps<'button'>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

75
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,75 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }

View File

@@ -0,0 +1,26 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

155
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { MagnifyingGlassIcon } from "@radix-ui/react-icons"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Empty
ref={ref}
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,39 @@
'use client'
import { Button } from '@/components/ui/button'
import { ArrowRight } from 'lucide-react'
interface CustomSolutionCTAProps {
title?: string
description: string
buttonText?: string
buttonHref?: string
className?: string
}
export function CustomSolutionCTA({
title = "Need Something Custom?",
description,
buttonText = "Contact Us",
buttonHref,
className = ""
}: CustomSolutionCTAProps) {
const handleClick = () => {
if (buttonHref) {
window.location.href = buttonHref
}
}
return (
<div className={`text-center mt-16 p-8 bg-muted/50 rounded-lg ${className}`}>
<h3 className="text-2xl font-semibold mb-4">{title}</h3>
<p className="text-muted-foreground mb-6">
{description}
</p>
<Button size="lg" onClick={buttonHref ? handleClick : undefined}>
{buttonText}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
)
}

129
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,129 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,228 @@
'use client'
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

152
components/ui/form.tsx Normal file
View File

@@ -0,0 +1,152 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div data-slot="form-item" className={cn('grid gap-2', className)} {...props} />
</FormItemContext.Provider>
)
}
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? '') : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,27 @@
import * as React from 'react'
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import { cn } from '@/lib/utils'
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

21
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{...props}
/>
)
}
export { Input }

21
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,21 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,120 @@
import * as React from 'react'
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu'
import { cva } from 'class-variance-authority'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn('relative z-10 flex max-w-max flex-1 items-center justify-center', className)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn('group flex flex-1 list-none items-center justify-center space-x-1', className)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50'
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
'left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ',
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn('absolute left-0 top-full flex justify-center')}>
<NavigationMenuPrimitive.Viewport
className={cn(
'origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]',
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
'top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in',
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@@ -0,0 +1,117 @@
import * as React from 'react'
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
import { cn } from '@/lib/utils'
import { ButtonProps, buttonVariants } from '@/components/ui/button'
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
)
Pagination.displayName = 'Pagination'
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
))
PaginationContent.displayName = 'PaginationContent'
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
))
PaginationItem.displayName = 'PaginationItem'
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>
const PaginationLink = ({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = 'PaginationLink'
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 pl-2.5', className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = 'PaginationPrevious'
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 pr-2.5', className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = 'PaginationNext'
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = 'PaginationEllipsis'
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

29
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,29 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/lib/utils'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -0,0 +1,23 @@
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-4 w-full overflow-hidden rounded-full bg-secondary', className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface RadioGroupContextValue {
value?: string
onValueChange?: (value: string) => void
name?: string
}
const RadioGroupContext = React.createContext<RadioGroupContextValue>({})
interface RadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {
value?: string
onValueChange?: (value: string) => void
name?: string
}
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
({ className, value, onValueChange, name, ...props }, ref) => {
return (
<RadioGroupContext.Provider value={{ value, onValueChange, name }}>
<div
className={cn("grid gap-2", className)}
{...props}
ref={ref}
role="radiogroup"
/>
</RadioGroupContext.Provider>
)
}
)
RadioGroup.displayName = "RadioGroup"
interface RadioGroupItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
value: string
}
const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
({ className, value, ...props }, ref) => {
const context = React.useContext(RadioGroupContext)
return (
<input
ref={ref}
type="radio"
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
name={context.name}
value={value}
checked={context.value === value}
onChange={() => context.onValueChange?.(value)}
{...props}
/>
)
}
)
RadioGroupItem.displayName = "RadioGroupItem"
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,44 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

170
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,170 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,24 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

130
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,130 @@
'use client'
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
}
export { Skeleton }

27
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,27 @@
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

53
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,53 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,18 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{...props}
/>
)
}
export { Textarea }

122
components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,122 @@
import * as React from 'react'
import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

33
components/ui/toaster.tsx Normal file
View File

@@ -0,0 +1,33 @@
'use client'
import { useToast } from '@/hooks/use-toast'
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast'
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

28
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,28 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }