first commit
This commit is contained in:
14
components/ClientFloatingWhatsApp.tsx
Normal file
14
components/ClientFloatingWhatsApp.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { LazyFloatingWhatsApp } from './DynamicComponents'
|
||||
|
||||
interface ClientFloatingWhatsAppProps {
|
||||
phoneNumber: string
|
||||
message: string
|
||||
position: "bottom-right" | "bottom-left"
|
||||
showTooltip: boolean
|
||||
}
|
||||
|
||||
export default function ClientFloatingWhatsApp(props: ClientFloatingWhatsAppProps) {
|
||||
return <LazyFloatingWhatsApp {...props} />
|
||||
}
|
||||
129
components/DynamicComponents.tsx
Normal file
129
components/DynamicComponents.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
// Dynamic Components for Bundle Optimization
|
||||
// This file implements lazy loading and code splitting for better performance
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
// Loading components
|
||||
const LoadingSpinner = () => (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600"></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const LoadingCard = () => (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 animate-pulse">
|
||||
<div className="h-48 bg-gray-200 rounded-md mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const LoadingForm = () => (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="space-y-4">
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
<div className="h-24 bg-gray-200 rounded"></div>
|
||||
<div className="h-10 bg-gray-200 rounded w-32"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Lazy loaded components with proper error boundaries
|
||||
export const LazyProductCard = dynamic(
|
||||
() => import('@/components/ProductCard').then(mod => mod.default),
|
||||
{
|
||||
loading: () => <LoadingCard />,
|
||||
ssr: true
|
||||
}
|
||||
)
|
||||
|
||||
export const LazyContactForm = dynamic(
|
||||
() => import('@/components/forms/ContactForm').then(mod => mod.default),
|
||||
{
|
||||
loading: () => <LoadingForm />,
|
||||
ssr: false // Contact form doesn't need SSR
|
||||
}
|
||||
)
|
||||
|
||||
export const LazyFloatingWhatsApp = dynamic(
|
||||
() => import('@/components/FloatingWhatsApp').then(mod => mod.default),
|
||||
{
|
||||
loading: () => null, // No loading state for floating elements
|
||||
ssr: false // WhatsApp widget doesn't need SSR
|
||||
}
|
||||
)
|
||||
|
||||
export const LazyImageSlider = dynamic(
|
||||
() => import('@/components/ImageSlider').then(mod => mod.default),
|
||||
{
|
||||
loading: () => <LoadingSpinner />,
|
||||
ssr: true
|
||||
}
|
||||
)
|
||||
|
||||
// Preload critical components for better UX
|
||||
export const preloadCriticalComponents = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Preload on interaction or after 3 seconds
|
||||
const preloadTimer = setTimeout(() => {
|
||||
import('@/components/ProductCard')
|
||||
import('@/components/FloatingWhatsApp')
|
||||
}, 3000)
|
||||
|
||||
// Preload on user interaction
|
||||
const preloadOnInteraction = () => {
|
||||
import('@/components/forms/ContactForm')
|
||||
clearTimeout(preloadTimer)
|
||||
document.removeEventListener('mouseover', preloadOnInteraction)
|
||||
document.removeEventListener('touchstart', preloadOnInteraction)
|
||||
}
|
||||
|
||||
document.addEventListener('mouseover', preloadOnInteraction, { once: true })
|
||||
document.addEventListener('touchstart', preloadOnInteraction, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper component with Suspense for better error handling
|
||||
export const DynamicComponentWrapper = ({
|
||||
children,
|
||||
fallback = <LoadingSpinner />
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
fallback?: React.ReactNode
|
||||
}) => (
|
||||
<Suspense fallback={fallback}>
|
||||
{children}
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
// Bundle optimization utility
|
||||
export const optimizeBundle = {
|
||||
// Check if component should be loaded
|
||||
shouldLoad: (component: string) => {
|
||||
if (typeof window === 'undefined') return true
|
||||
|
||||
// Load based on viewport, connection, and device capabilities
|
||||
const isInViewport = 'IntersectionObserver' in window
|
||||
const hasGoodConnection = (navigator as any).connection?.effectiveType?.includes('4g') ?? true
|
||||
const deviceMemory = (navigator as any).deviceMemory
|
||||
const hasEnoughMemory = deviceMemory ? deviceMemory > 4 : true
|
||||
|
||||
return isInViewport && hasGoodConnection && hasEnoughMemory
|
||||
},
|
||||
|
||||
// Prefetch resources
|
||||
prefetch: (resources: string[]) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
resources.forEach(resource => {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'prefetch'
|
||||
link.href = resource
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
141
components/FloatingWhatsApp.tsx
Normal file
141
components/FloatingWhatsApp.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { MessageCircle, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface FloatingWhatsAppProps {
|
||||
phoneNumber: string
|
||||
message?: string
|
||||
position?: 'bottom-right' | 'bottom-left'
|
||||
showTooltip?: boolean
|
||||
}
|
||||
|
||||
export default function FloatingWhatsApp({
|
||||
phoneNumber,
|
||||
message = "Hello! I'm interested in your products.",
|
||||
position = 'bottom-right',
|
||||
showTooltip = true
|
||||
}: FloatingWhatsAppProps) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [showTooltipState, setShowTooltipState] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Show the button after 3 seconds
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true)
|
||||
|
||||
// Show tooltip after button appears (if enabled)
|
||||
if (showTooltip) {
|
||||
setTimeout(() => {
|
||||
setShowTooltipState(true)
|
||||
}, 1000)
|
||||
|
||||
// Hide tooltip after 5 seconds
|
||||
setTimeout(() => {
|
||||
setShowTooltipState(false)
|
||||
}, 6000)
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [showTooltip])
|
||||
|
||||
const handleWhatsAppClick = () => {
|
||||
const encodedMessage = encodeURIComponent(message)
|
||||
const whatsappUrl = `https://wa.me/${phoneNumber}?text=${encodedMessage}`
|
||||
window.open(whatsappUrl, '_blank')
|
||||
}
|
||||
|
||||
const positionClasses = {
|
||||
'bottom-right': 'bottom-6 right-6',
|
||||
'bottom-left': 'bottom-6 left-6'
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 20
|
||||
}}
|
||||
className={`fixed ${positionClasses[position]} z-50 md:mb-0 mb-12`}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Tooltip */}
|
||||
<AnimatePresence>
|
||||
{showTooltipState && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: position === 'bottom-right' ? 20 : -20, y: 20 }}
|
||||
animate={{ opacity: 1, x: 0, y: 0 }}
|
||||
exit={{ opacity: 0, x: position === 'bottom-right' ? 20 : -20, y: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`absolute ${
|
||||
position === 'bottom-right'
|
||||
? 'right-16 bottom-0'
|
||||
: 'left-16 bottom-0'
|
||||
} mb-2`}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-lg p-3 max-w-xs relative border">
|
||||
<button
|
||||
onClick={() => setShowTooltipState(false)}
|
||||
className="absolute -top-2 -right-2 bg-gray-100 hover:bg-gray-200 rounded-full p-1 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3 text-gray-600" />
|
||||
</button>
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
👋 Need help? Chat with us on WhatsApp!
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
We typically reply within minutes
|
||||
</p>
|
||||
{/* Arrow */}
|
||||
<div className={`absolute top-1/2 transform -translate-y-1/2 ${
|
||||
position === 'bottom-right'
|
||||
? '-right-2 border-l-white border-l-8 border-t-transparent border-b-transparent border-t-8 border-b-8'
|
||||
: '-left-2 border-r-white border-r-8 border-t-transparent border-b-transparent border-t-8 border-b-8'
|
||||
} w-0 h-0`}></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* WhatsApp Button */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className="relative"
|
||||
>
|
||||
<Button
|
||||
onClick={handleWhatsAppClick}
|
||||
className="h-14 w-14 rounded-full bg-green-500 hover:bg-green-600 text-white shadow-lg hover:shadow-xl transition-all duration-300 p-0 relative overflow-hidden group"
|
||||
aria-label="Contact us on WhatsApp"
|
||||
>
|
||||
{/* Ripple effect background */}
|
||||
<div className="absolute inset-0 bg-green-400 rounded-full opacity-75 animate-ping"></div>
|
||||
|
||||
{/* WhatsApp icon */}
|
||||
<div className="relative z-10">
|
||||
<svg
|
||||
className="h-7 w-7"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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-.57-.01-.198 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.89-5.335 11.893-11.893A11.821 11.821 0 0020.893 3.488"/>
|
||||
</svg>
|
||||
</div>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
191
components/ImageSlider.tsx
Normal file
191
components/ImageSlider.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
|
||||
interface ImageSliderProps {
|
||||
images: {
|
||||
src: string
|
||||
alt: string
|
||||
title?: string
|
||||
}[]
|
||||
autoPlayInterval?: number
|
||||
}
|
||||
|
||||
export default function ImageSlider({ images, autoPlayInterval = 5000 }: ImageSliderProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [imagesLoaded, setImagesLoaded] = useState<boolean[]>(new Array(images.length).fill(false))
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Preload all images
|
||||
useEffect(() => {
|
||||
const preloadImages = async () => {
|
||||
const imagePromises = images.map((image, index) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const img = new window.Image()
|
||||
img.onload = () => {
|
||||
setImagesLoaded(prev => {
|
||||
const newLoaded = [...prev]
|
||||
newLoaded[index] = true
|
||||
return newLoaded
|
||||
})
|
||||
resolve()
|
||||
}
|
||||
img.onerror = () => {
|
||||
console.warn(`Failed to load image: ${image.src}`)
|
||||
setImagesLoaded(prev => {
|
||||
const newLoaded = [...prev]
|
||||
newLoaded[index] = true // Mark as loaded even if failed to avoid infinite loading
|
||||
return newLoaded
|
||||
})
|
||||
resolve() // Don't reject to avoid breaking the Promise.all
|
||||
}
|
||||
img.src = image.src
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(imagePromises)
|
||||
setIsLoading(false)
|
||||
} catch (error) {
|
||||
console.error('Error preloading images:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
preloadImages()
|
||||
}, [images])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlayInterval > 0 && !isLoading) {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % images.length)
|
||||
}, autoPlayInterval)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [images.length, autoPlayInterval, isLoading])
|
||||
|
||||
const goToPrevious = () => {
|
||||
setCurrentIndex((prev) => (prev - 1 + images.length) % images.length)
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
setCurrentIndex((prev) => (prev + 1) % images.length)
|
||||
}
|
||||
|
||||
const goToSlide = (index: number) => {
|
||||
setCurrentIndex(index)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden rounded-lg" style={{ aspectRatio: '21/9' }}>
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-gray-200 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500 mx-auto mb-2" />
|
||||
<p className="text-gray-600 text-sm">Loading images...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Container */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, x: 100 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -100 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="relative w-full h-full"
|
||||
>
|
||||
<Image
|
||||
src={images[currentIndex].src}
|
||||
alt={images[currentIndex].alt}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority={currentIndex === 0}
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWGRkqGx0f/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyJckliyjqTzSlT54b6bk+h0R//2Q=="
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 70vw"
|
||||
onLoad={() => {
|
||||
setImagesLoaded(prev => {
|
||||
const newLoaded = [...prev]
|
||||
newLoaded[currentIndex] = true
|
||||
return newLoaded
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Image Loading Skeleton */}
|
||||
{!imagesLoaded[currentIndex] && (
|
||||
<div className="absolute inset-0 bg-gray-200 animate-pulse flex items-center justify-center">
|
||||
<div className="text-gray-400">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
|
||||
{/* Title Overlay */}
|
||||
{images[currentIndex].title && (
|
||||
<div className="absolute bottom-8 left-8 right-8">
|
||||
<motion.h3
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-2xl md:text-3xl font-bold text-white drop-shadow-lg"
|
||||
>
|
||||
{images[currentIndex].title}
|
||||
</motion.h3>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation Arrows - Only show when not loading */}
|
||||
{!isLoading && (
|
||||
<>
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 bg-white/20 backdrop-blur-sm hover:bg-white/30 text-white p-2 rounded-full transition-all duration-200"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 bg-white/20 backdrop-blur-sm hover:bg-white/30 text-white p-2 rounded-full transition-all duration-200"
|
||||
aria-label="Next image"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dots Indicator - Only show when not loading */}
|
||||
{!isLoading && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex space-x-2">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => goToSlide(index)}
|
||||
className={`w-3 h-3 rounded-full transition-all duration-200 ${
|
||||
index === currentIndex
|
||||
? 'bg-white scale-110'
|
||||
: 'bg-white/50 hover:bg-white/70'
|
||||
}`}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
343
components/LazyComponents.tsx
Normal file
343
components/LazyComponents.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
'use client'
|
||||
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { ProductGridSkeleton } from '@/components/ui/LazyLoader'
|
||||
|
||||
// Lazy load heavy components - Client side only
|
||||
const ProductGrid = lazy(() => import('@/components/shop/ProductGrid'))
|
||||
const OurValues = lazy(() => import('@/components/sections/OurValues'))
|
||||
const AboutSection = lazy(() => import('@/components/sections/AboutSection'))
|
||||
const HeroSection = lazy(() => import('@/components/sections/HeroSection'))
|
||||
const CertificationsSection = lazy(() => import('@/components/sections/CertificationsSection'))
|
||||
const KashminaSection = lazy(() => import('@/components/sections/KashminaSection'))
|
||||
const ManufacturingSection = lazy(() => import('@/components/sections/ManufacturingSection'))
|
||||
const StatsSection = lazy(() => import('@/components/sections/StatsSection'))
|
||||
const SustainabilitySection = lazy(() => import('@/components/sections/SustainabilitySection'))
|
||||
const NewsSection = lazy(() => import('@/components/sections/NewsSection'))
|
||||
|
||||
// Lazy Product Grid with skeleton
|
||||
export function LazyProductGrid(props: any) {
|
||||
return (
|
||||
<Suspense fallback={<ProductGridSkeleton />}>
|
||||
<ProductGrid {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// Lazy Our Values with skeleton
|
||||
export function LazyOurValues() {
|
||||
const fallback = (
|
||||
<div className="py-20 bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-12">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<div className="h-6 w-24 bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
<div className="h-8 w-64 bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
<div className="h-4 w-96 bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-64 bg-white rounded-lg shadow-lg animate-pulse">
|
||||
<div className="p-8 space-y-4">
|
||||
<div className="w-16 h-16 bg-gray-200 rounded-full mx-auto"></div>
|
||||
<div className="h-6 w-32 bg-gray-200 rounded mx-auto"></div>
|
||||
<div className="h-4 w-full bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-3/4 bg-gray-200 rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<OurValues />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// Lazy About Section with skeleton
|
||||
export function LazyAboutSection() {
|
||||
const fallback = (
|
||||
<div className="py-20 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 w-64 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 w-full bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-4 w-full bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-4 w-3/4 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
<div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
<div className="h-96 bg-gray-200 rounded-lg animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<AboutSection />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// Lazy Hero Section with skeleton
|
||||
export function LazyHeroSection() {
|
||||
const fallback = (
|
||||
<div className="relative h-screen bg-gradient-to-br from-emerald-50 to-blue-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full flex items-center">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center w-full">
|
||||
<div className="space-y-8">
|
||||
<div className="h-12 w-96 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 w-full bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-4 w-3/4 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
<div className="flex space-x-4">
|
||||
<div className="h-12 w-32 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-12 w-32 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-96 bg-gray-200 rounded-lg animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<HeroSection />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// Lazy Certifications Section with skeleton
|
||||
export function LazyCertificationsSection() {
|
||||
const fallback = (
|
||||
<div className="py-12 md:py-16 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center space-y-4 mb-8 md:mb-12">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
<div className="h-4 w-96 bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center items-center gap-6 md:gap-8 lg:gap-12">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={i} className="text-center animate-pulse">
|
||||
<div className="w-20 h-20 md:w-24 md:h-24 lg:w-28 lg:h-28 bg-gray-200 rounded-full mx-auto"></div>
|
||||
<div className="h-3 w-12 bg-gray-200 rounded mt-2 mx-auto"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center mt-8 md:mt-12">
|
||||
<div className="h-4 w-96 bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<CertificationsSection />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// Lazy Kashmina Section with skeleton
|
||||
export function LazyKashminaSection() {
|
||||
const fallback = (
|
||||
<div className="py-16 md:py-24 bg-gradient-to-br from-amber-50 to-red-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center space-y-4 mb-12 md:mb-16">
|
||||
<div className="h-6 w-32 bg-gray-200 rounded-full mx-auto animate-pulse"></div>
|
||||
<div className="h-12 w-48 bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
<div className="h-5 w-96 bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16 items-center mb-16">
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 w-64 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 w-full bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-4 w-full bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-4 w-3/4 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 bg-gray-200 rounded-full animate-pulse"></div>
|
||||
<div className="h-4 w-48 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="h-12 w-40 bg-gray-200 rounded-xl animate-pulse"></div>
|
||||
<div className="h-12 w-32 bg-gray-200 rounded-xl animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-96 bg-gray-200 rounded-3xl animate-pulse"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-lg shadow-lg p-6 animate-pulse">
|
||||
<div className="w-12 h-12 bg-gray-200 rounded-xl mb-4"></div>
|
||||
<div className="h-5 w-32 bg-gray-200 rounded mb-2"></div>
|
||||
<div className="h-4 w-full bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<KashminaSection />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// Lazy Manufacturing Section with skeleton
|
||||
export function LazyManufacturingSection() {
|
||||
const fallback = (
|
||||
<div className="py-16 lg:py-24 bg-gradient-to-br from-slate-50 via-white to-emerald-50/30">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<div className="h-6 w-32 bg-gray-200 rounded-full mx-auto animate-pulse"></div>
|
||||
<div className="h-12 w-96 bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
<div className="h-5 w-[600px] bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-20">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl p-6 shadow-lg animate-pulse">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-gray-200 rounded-xl"></div>
|
||||
<div className="text-right">
|
||||
<div className="h-6 w-16 bg-gray-200 rounded"></div>
|
||||
<div className="h-3 w-20 bg-gray-200 rounded mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-5 w-32 bg-gray-200 rounded mb-2"></div>
|
||||
<div className="h-4 w-full bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<ManufacturingSection />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// Lazy Stats Section with skeleton
|
||||
export function LazyStatsSection() {
|
||||
const fallback = (
|
||||
<div className="py-16 lg:py-24 bg-gradient-to-br from-gray-900 via-slate-800 to-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<div className="h-6 w-24 bg-gray-700 rounded-full mx-auto animate-pulse"></div>
|
||||
<div className="h-12 w-96 bg-gray-700 rounded mx-auto animate-pulse"></div>
|
||||
<div className="h-5 w-[600px] bg-gray-700 rounded mx-auto animate-pulse"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="bg-white/5 rounded-2xl p-8 border border-white/10 animate-pulse">
|
||||
<div className="w-16 h-16 bg-gray-700 rounded-2xl mb-6"></div>
|
||||
<div className="h-12 w-20 bg-gray-700 rounded mb-2"></div>
|
||||
<div className="h-5 w-32 bg-gray-700 rounded mb-3"></div>
|
||||
<div className="h-4 w-full bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<StatsSection />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Lazy Sustainability Section with skeleton
|
||||
export function LazySustainabilitySection() {
|
||||
const fallback = (
|
||||
<div className="py-16 lg:py-24 bg-gradient-to-br from-green-50 via-emerald-50 to-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<div className="h-6 w-32 bg-gray-200 rounded-full mx-auto animate-pulse"></div>
|
||||
<div className="h-12 w-96 bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
<div className="h-5 w-[600px] bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-20">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl shadow-lg p-6 border border-gray-100 animate-pulse">
|
||||
<div className="w-16 h-16 bg-gray-200 rounded-2xl mb-6"></div>
|
||||
<div className="h-5 w-32 bg-gray-200 rounded mb-3"></div>
|
||||
<div className="h-4 w-full bg-gray-200 rounded mb-4"></div>
|
||||
<div className="h-6 w-24 bg-gray-200 rounded-full"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<SustainabilitySection />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// Lazy News Section with skeleton
|
||||
export function LazyNewsSection() {
|
||||
const fallback = (
|
||||
<div className="py-16 lg:py-24 bg-gradient-to-br from-slate-50 via-white to-blue-50/30">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<div className="h-6 w-32 bg-gray-200 rounded-full mx-auto animate-pulse"></div>
|
||||
<div className="h-12 w-64 bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
<div className="h-5 w-[500px] bg-gray-200 rounded mx-auto animate-pulse"></div>
|
||||
</div>
|
||||
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden border border-gray-100 mb-16 animate-pulse">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="h-64 lg:h-80 bg-gray-200"></div>
|
||||
<div className="p-8 lg:p-12 space-y-4">
|
||||
<div className="h-4 w-32 bg-gray-200 rounded"></div>
|
||||
<div className="h-8 w-full bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-full bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-3/4 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl shadow-lg overflow-hidden border border-gray-100 animate-pulse">
|
||||
<div className="h-48 bg-gray-200"></div>
|
||||
<div className="p-6 space-y-3">
|
||||
<div className="h-4 w-32 bg-gray-200 rounded"></div>
|
||||
<div className="h-5 w-full bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-full bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<NewsSection />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
295
components/MobileOptimizedLazyComponents.tsx
Normal file
295
components/MobileOptimizedLazyComponents.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'use client'
|
||||
|
||||
import React, { lazy, Suspense, useEffect, useState, useRef } from 'react'
|
||||
import { ProductGridSkeleton } from '@/components/ui/LazyLoader'
|
||||
|
||||
// Helper to detect mobile devices
|
||||
const isMobile = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.innerWidth <= 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
// More aggressive lazy loading for mobile devices
|
||||
const HeroSection = lazy(() => import('@/components/sections/HeroSection'))
|
||||
const AboutSection = lazy(() => import('@/components/sections/AboutSection'))
|
||||
const KashminaSection = lazy(() =>
|
||||
isMobile()
|
||||
? import('@/components/sections/KashminaSection').then(module => ({ default: module.default }))
|
||||
: import('@/components/sections/KashminaSection')
|
||||
)
|
||||
const ManufacturingSection = lazy(() =>
|
||||
import('@/components/sections/ManufacturingSection').then(module => ({ default: module.default }))
|
||||
)
|
||||
const StatsSection = lazy(() =>
|
||||
import('@/components/sections/StatsSection').then(module => ({ default: module.default }))
|
||||
)
|
||||
const CertificationsSection = lazy(() =>
|
||||
import('@/components/sections/CertificationsSection').then(module => ({ default: module.default }))
|
||||
)
|
||||
const NewsSection = lazy(() =>
|
||||
import('@/components/sections/NewsSection').then(module => ({ default: module.default }))
|
||||
)
|
||||
const OurValues = lazy(() =>
|
||||
import('@/components/sections/OurValues').then(module => ({ default: module.default }))
|
||||
)
|
||||
|
||||
// Intersection Observer for better mobile performance
|
||||
const useIntersectionObserver = (ref: React.RefObject<HTMLElement | null>, threshold = 0.1) => {
|
||||
const [isIntersecting, setIsIntersecting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const current = ref.current
|
||||
if (!current) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsIntersecting(true)
|
||||
observer.unobserve(current)
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin: '100px' }
|
||||
)
|
||||
|
||||
observer.observe(current)
|
||||
return () => observer.disconnect()
|
||||
}, [ref, threshold])
|
||||
|
||||
return isIntersecting
|
||||
}
|
||||
|
||||
// Mobile-optimized lazy wrapper
|
||||
function MobileLazyWrapper({
|
||||
children,
|
||||
fallback,
|
||||
className = '',
|
||||
priority = false
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
fallback: React.ReactNode
|
||||
className?: string
|
||||
priority?: boolean
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const isVisible = useIntersectionObserver(ref as React.RefObject<HTMLElement>)
|
||||
const [shouldRender, setShouldRender] = useState(priority)
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible || priority) {
|
||||
// Add small delay for mobile to prevent blocking
|
||||
const timer = setTimeout(() => setShouldRender(true), isMobile() ? 50 : 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isVisible, priority])
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
{shouldRender ? (
|
||||
<Suspense fallback={fallback}>
|
||||
{children}
|
||||
</Suspense>
|
||||
) : (
|
||||
fallback
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Optimized lazy components with better mobile performance
|
||||
export function MobileOptimizedLazyHeroSection() {
|
||||
const fallback = (
|
||||
<div className="relative min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 animate-pulse">
|
||||
<div className="absolute inset-0 bg-black/20"></div>
|
||||
<div className="relative z-10 flex items-center justify-center min-h-screen">
|
||||
<div className="text-center space-y-6 px-4">
|
||||
<div className="h-16 w-96 bg-white/20 rounded-lg mx-auto"></div>
|
||||
<div className="h-6 w-64 bg-white/20 rounded mx-auto"></div>
|
||||
<div className="h-12 w-48 bg-white/20 rounded-lg mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<MobileLazyWrapper fallback={fallback} priority={true}>
|
||||
<HeroSection />
|
||||
</MobileLazyWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileOptimizedLazyAboutSection() {
|
||||
const fallback = (
|
||||
<div className="py-20 bg-white animate-pulse">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<div className="h-6 w-24 bg-gray-200 rounded mx-auto"></div>
|
||||
<div className="h-8 w-64 bg-gray-200 rounded mx-auto"></div>
|
||||
<div className="h-4 w-96 bg-gray-200 rounded mx-auto"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div className="h-64 bg-gray-200 rounded-lg"></div>
|
||||
<div className="space-y-4">
|
||||
<div className="h-6 w-full bg-gray-200 rounded"></div>
|
||||
<div className="h-6 w-5/6 bg-gray-200 rounded"></div>
|
||||
<div className="h-6 w-4/6 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<MobileLazyWrapper fallback={fallback}>
|
||||
<AboutSection />
|
||||
</MobileLazyWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileOptimizedLazyKashminaSection() {
|
||||
const fallback = (
|
||||
<div className="py-20 bg-gradient-to-br from-emerald-50 to-green-50 animate-pulse">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<div className="h-6 w-32 bg-gray-200 rounded mx-auto"></div>
|
||||
<div className="h-8 w-64 bg-gray-200 rounded mx-auto"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<div className="h-96 bg-gray-200 rounded-lg"></div>
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 w-full bg-gray-200 rounded"></div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-4 w-full bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<MobileLazyWrapper fallback={fallback}>
|
||||
<KashminaSection />
|
||||
</MobileLazyWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileOptimizedLazyManufacturingSection() {
|
||||
const fallback = (
|
||||
<div className="py-20 bg-slate-50 animate-pulse">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<div className="h-6 w-40 bg-gray-200 rounded mx-auto"></div>
|
||||
<div className="h-8 w-72 bg-gray-200 rounded mx-auto"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-64 bg-white rounded-lg shadow-sm"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<MobileLazyWrapper fallback={fallback}>
|
||||
<ManufacturingSection />
|
||||
</MobileLazyWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileOptimizedLazyStatsSection() {
|
||||
const fallback = (
|
||||
<div className="py-20 bg-gradient-to-r from-blue-600 to-purple-600 animate-pulse">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div className="h-12 w-20 bg-white/20 rounded mx-auto mb-4"></div>
|
||||
<div className="h-4 w-24 bg-white/20 rounded mx-auto"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<MobileLazyWrapper fallback={fallback}>
|
||||
<StatsSection />
|
||||
</MobileLazyWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileOptimizedLazyCertificationsSection() {
|
||||
const fallback = (
|
||||
<div className="py-20 bg-white animate-pulse">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<div className="h-6 w-32 bg-gray-200 rounded mx-auto"></div>
|
||||
<div className="h-8 w-64 bg-gray-200 rounded mx-auto"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-32 bg-gray-200 rounded-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<MobileLazyWrapper fallback={fallback}>
|
||||
<CertificationsSection />
|
||||
</MobileLazyWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileOptimizedLazyNewsSection() {
|
||||
const fallback = (
|
||||
<div className="py-20 bg-slate-50 animate-pulse">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<div className="h-6 w-32 bg-gray-200 rounded mx-auto"></div>
|
||||
<div className="h-8 w-64 bg-gray-200 rounded mx-auto"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-64 bg-white rounded-lg shadow-sm"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<MobileLazyWrapper fallback={fallback}>
|
||||
<NewsSection />
|
||||
</MobileLazyWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileOptimizedLazyOurValues() {
|
||||
const fallback = (
|
||||
<div className="py-20 bg-gradient-to-br from-slate-50 to-blue-50 animate-pulse">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<div className="h-6 w-24 bg-gray-200 rounded mx-auto"></div>
|
||||
<div className="h-8 w-64 bg-gray-200 rounded mx-auto"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-64 bg-white rounded-lg shadow-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<MobileLazyWrapper fallback={fallback}>
|
||||
<OurValues />
|
||||
</MobileLazyWrapper>
|
||||
)
|
||||
}
|
||||
224
components/PWAInstallPrompt.tsx
Normal file
224
components/PWAInstallPrompt.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { X, Download, Smartphone } from 'lucide-react'
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||
}
|
||||
|
||||
export default function PWAInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
||||
const [showPrompt, setShowPrompt] = useState(false)
|
||||
const [isInstalled, setIsInstalled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if app is already installed
|
||||
const checkInstalled = () => {
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||
const isInWebAppiOS = (window.navigator as any).standalone === true
|
||||
const isAndroidInstalled = document.referrer.includes('android-app://')
|
||||
|
||||
setIsInstalled(isStandalone || isInWebAppiOS || isAndroidInstalled)
|
||||
}
|
||||
|
||||
checkInstalled()
|
||||
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault()
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent)
|
||||
|
||||
// Don't show prompt immediately, wait a bit for user engagement
|
||||
setTimeout(() => {
|
||||
if (!isInstalled && !localStorage.getItem('pwa-install-dismissed')) {
|
||||
setShowPrompt(true)
|
||||
}
|
||||
}, 10000) // Show after 10 seconds
|
||||
}
|
||||
|
||||
const handleAppInstalled = () => {
|
||||
setShowPrompt(false)
|
||||
setIsInstalled(true)
|
||||
setDeferredPrompt(null)
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.addEventListener('appinstalled', handleAppInstalled)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.removeEventListener('appinstalled', handleAppInstalled)
|
||||
}
|
||||
}, [isInstalled])
|
||||
|
||||
const handleInstallClick = async () => {
|
||||
if (!deferredPrompt) return
|
||||
|
||||
try {
|
||||
await deferredPrompt.prompt()
|
||||
const choiceResult = await deferredPrompt.userChoice
|
||||
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
console.log('User accepted the install prompt')
|
||||
} else {
|
||||
console.log('User dismissed the install prompt')
|
||||
}
|
||||
|
||||
setDeferredPrompt(null)
|
||||
setShowPrompt(false)
|
||||
} catch (error) {
|
||||
console.error('Error showing install prompt:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false)
|
||||
localStorage.setItem('pwa-install-dismissed', 'true')
|
||||
|
||||
// Show again after 7 days
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('pwa-install-dismissed')
|
||||
}, 7 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
if (isInstalled || !showPrompt || !deferredPrompt) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 100 }}
|
||||
className="fixed bottom-4 left-4 right-4 z-50 max-w-sm mx-auto"
|
||||
>
|
||||
<Card className="bg-gradient-to-r from-green-500 to-emerald-600 text-white border-0 shadow-2xl">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<Smartphone className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-white mb-1">
|
||||
Install Padmaaja Rasooi App
|
||||
</h3>
|
||||
<p className="text-xs text-green-100 mb-3">
|
||||
Get the full experience with offline access, faster loading, and push notifications.
|
||||
</p>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
onClick={handleInstallClick}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="flex-1 text-green-700 hover:text-green-800"
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Install
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDismiss}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDismiss}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="flex-shrink-0 text-white hover:bg-white/20 p-1"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// Alternative iOS Safari install instructions
|
||||
export function IOSInstallPrompt() {
|
||||
const [showIOS, setShowIOS] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||
const isInStandaloneMode = (window.navigator as any).standalone === true
|
||||
const hasPromptBeenShown = localStorage.getItem('ios-install-prompt-shown')
|
||||
|
||||
if (isIOS && !isInStandaloneMode && !hasPromptBeenShown) {
|
||||
setTimeout(() => setShowIOS(true), 15000) // Show after 15 seconds on iOS
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowIOS(false)
|
||||
localStorage.setItem('ios-install-prompt-shown', 'true')
|
||||
}
|
||||
|
||||
if (!showIOS) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 100 }}
|
||||
className="fixed bottom-4 left-4 right-4 z-50 max-w-sm mx-auto"
|
||||
>
|
||||
<Card className="bg-blue-600 text-white border-0 shadow-2xl">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<Smartphone className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-white mb-1">
|
||||
Add to Home Screen
|
||||
</h3>
|
||||
<p className="text-xs text-blue-100 mb-2">
|
||||
Tap the share button <span className="inline-block w-4 h-4 bg-white/20 rounded text-center text-xs">⬆️</span> below and select "Add to Home Screen"
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={handleDismiss}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="text-blue-700 hover:text-blue-800"
|
||||
>
|
||||
Got it
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDismiss}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="flex-shrink-0 text-white hover:bg-white/20 p-1"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
443
components/ProductCard.tsx
Normal file
443
components/ProductCard.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ShoppingCart, Star, Eye, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import OptimizedImage from '@/components/ui/OptimizedImage'
|
||||
import { motion } from 'framer-motion'
|
||||
import { cartManager } from '@/lib/cart'
|
||||
import { toast } from 'sonner'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Product } from '@/types'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface ProductReviewStats {
|
||||
averageRating: number
|
||||
totalReviews: number
|
||||
}
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product
|
||||
index: number
|
||||
}
|
||||
|
||||
export default function ProductCard({ product, index }: ProductCardProps) {
|
||||
const router = useRouter()
|
||||
const [isQuickViewOpen, setIsQuickViewOpen] = useState(false)
|
||||
const [reviewStats, setReviewStats] = useState<ProductReviewStats>({
|
||||
averageRating: 0,
|
||||
totalReviews: 0
|
||||
})
|
||||
|
||||
// Fetch review statistics for the product
|
||||
useEffect(() => {
|
||||
const fetchReviewStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/reviews?productId=${product.id}&limit=1000`)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.reviews) {
|
||||
const reviews = data.reviews
|
||||
const totalReviews = reviews.length
|
||||
|
||||
if (totalReviews > 0) {
|
||||
const sum = reviews.reduce((acc: number, review: any) => acc + review.rating, 0)
|
||||
const averageRating = sum / totalReviews
|
||||
|
||||
setReviewStats({
|
||||
averageRating: Math.round(averageRating * 10) / 10, // Round to 1 decimal
|
||||
totalReviews
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch review stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchReviewStats()
|
||||
}, [product.id])
|
||||
|
||||
const getDiscountedPrice = (price: number, discount: number) => {
|
||||
return price - (price * discount / 100)
|
||||
}
|
||||
|
||||
// Helper function to calculate per kg price
|
||||
const getPerKgPrice = (price: number, weight: string | null, discount: number = 0) => {
|
||||
if (!weight) return null
|
||||
|
||||
// Extract numeric value from weight string (e.g., "1kg", "500g", "2.5 kg")
|
||||
const weightMatch = weight.toLowerCase().match(/(\d+(?:\.\d+)?)\s*(kg|g|gram|kilos?)/i)
|
||||
if (!weightMatch) return null
|
||||
|
||||
const value = parseFloat(weightMatch[1])
|
||||
const unit = weightMatch[2].toLowerCase()
|
||||
|
||||
// Convert to kg
|
||||
let weightInKg = value
|
||||
if (unit.startsWith('g')) {
|
||||
weightInKg = value / 1000
|
||||
}
|
||||
|
||||
const finalPrice = discount > 0 ? getDiscountedPrice(price, discount) : price
|
||||
return Math.round(finalPrice / weightInKg)
|
||||
}
|
||||
|
||||
const handleAddToCart = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
console.log('Add to cart clicked for:', product.name) // Debug log
|
||||
|
||||
if (product.stock === 0) {
|
||||
toast.error('Product is out of stock')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const success = cartManager.addToCart(product, 1)
|
||||
if (success) {
|
||||
console.log('Item added successfully') // Debug log
|
||||
toast.success(`${product.name} added to cart!`)
|
||||
} else {
|
||||
toast.error('Not enough stock available')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding to cart:', error)
|
||||
toast.error('Failed to add to cart')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardClick = () => {
|
||||
router.push(`/products/${product.slug}`)
|
||||
}
|
||||
|
||||
const handleViewDetails = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setIsQuickViewOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
itemScope
|
||||
itemType="https://schema.org/Product"
|
||||
role="article"
|
||||
aria-labelledby={`product-title-${product.id}`}
|
||||
aria-describedby={`product-description-${product.id}`}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card
|
||||
className="h-full hover:shadow-lg transition-all duration-300 group overflow-hidden cursor-pointer hover:scale-[1.02]"
|
||||
onClick={handleCardClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleCardClick()
|
||||
}
|
||||
}}
|
||||
aria-label={`View details for ${product.name}`}
|
||||
>
|
||||
<header className="relative overflow-hidden">
|
||||
<div itemProp="image">
|
||||
<OptimizedImage
|
||||
src={product.images[0] || 'https://images.pexels.com/photos/3683107/pexels-photo-3683107.jpeg'}
|
||||
alt={`${product.name} - Premium rice product from Padmaaja Rasooi`}
|
||||
width={400}
|
||||
height={300}
|
||||
className="w-full h-full object-contain group-hover:scale-105 transition-transform duration-300"
|
||||
priority={index < 4} // Prioritize first 4 products
|
||||
quality={85}
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 25vw"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per kg price badge - Top Left */}
|
||||
{getPerKgPrice(product.price, product.weight, product.discount) && (
|
||||
<Badge className="absolute top-2 left-2 sm:top-3 sm:left-3 bg-emerald-500 hover:bg-emerald-600 text-white font-semibold shadow-lg text-base">
|
||||
₹{getPerKgPrice(product.price, product.weight, product.discount)}/kg
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Discount badge - Top Left, below per kg badge if both exist */}
|
||||
{product.discount > 0 && (
|
||||
<Badge className={`absolute ${getPerKgPrice(product.price, product.weight, product.discount) ? 'top-8 sm:top-10' : 'top-2 sm:top-3'} left-2 sm:left-3 bg-red-500 hover:bg-red-600 text-white font-semibold shadow-lg text-xs`}>
|
||||
{product.discount}% OFF
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Dynamic Rating Display */}
|
||||
{reviewStats.totalReviews > 0 ? (
|
||||
<div className="absolute top-2 right-2 sm:top-3 sm:right-3 flex items-center bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 shadow-lg">
|
||||
<Star className="h-3 w-3 text-yellow-400 fill-current" />
|
||||
<span className="text-xs ml-1 font-medium">{reviewStats.averageRating}</span>
|
||||
<span className="text-xs ml-1 text-gray-500">({reviewStats.totalReviews})</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-2 right-2 sm:top-3 sm:right-3 flex items-center bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 shadow-lg">
|
||||
<Star className="h-3 w-3 text-gray-300" />
|
||||
<span className="text-xs ml-1 text-gray-400">No reviews</span>
|
||||
</div>
|
||||
)}
|
||||
{product.stock === 0 && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<Badge variant="destructive" className="text-xs sm:text-sm">Out of Stock</Badge>
|
||||
</div>
|
||||
)}
|
||||
<Badge className="absolute bottom-2 right-2 sm:bottom-3 sm:right-3 bg-white/90 text-slate-700 border border-slate-200 text-xs font-medium shadow-lg backdrop-blur-sm">
|
||||
{product.category.name}
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
<CardHeader className="pb-2 p-3 sm:pb-2" role="contentinfo">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardTitle
|
||||
className="text-base sm:text-lg font-semibold truncate group-hover:text-blue-600 transition-colors flex-1 leading-tight cursor-help"
|
||||
id={`product-title-${product.id}`}
|
||||
itemProp="name"
|
||||
>
|
||||
{product.name}
|
||||
</CardTitle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>{product.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<CardDescription
|
||||
className="line-clamp-2 md:text-xs text-sm text-gray-600 leading-relaxed"
|
||||
id={`product-description-${product.id}`}
|
||||
itemProp="description"
|
||||
>
|
||||
{product.description || 'Premium quality product from Padmaaja Rasooi Pvt. Ltd.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0 p-3 sm:p-6 sm:pt-0 space-y-3 sm:space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{product.discount > 0 ? (
|
||||
<>
|
||||
<span className="text-base sm:text-lg font-bold text-green-600">
|
||||
₹{getDiscountedPrice(product.price, product.discount).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm text-gray-500 line-through">
|
||||
₹{product.price.toFixed(2)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-base sm:text-lg font-bold text-gray-900">
|
||||
₹{product.price.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* <span className="text-xs sm:text-sm text-gray-500">
|
||||
Stock: <span className={product.stock > 0 ? 'text-green-600' : 'text-red-600'}>{product.stock}</span>
|
||||
</span> */}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddToCart}
|
||||
disabled={product.stock === 0}
|
||||
className="flex-1 text-xs sm:text-sm h-8 sm:h-9"
|
||||
>
|
||||
<ShoppingCart className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
||||
<span className="hidden sm:inline">{product.stock === 0 ? 'Out of Stock' : 'Add to Cart'}</span>
|
||||
<span className="sm:hidden">{product.stock === 0 ? 'Out' : 'Add'}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleViewDetails}
|
||||
className="px-2 sm:px-3 h-8 sm:h-9"
|
||||
>
|
||||
<Eye className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick View Dialog */}
|
||||
<Dialog open={isQuickViewOpen} onOpenChange={setIsQuickViewOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">{product.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Product Image */}
|
||||
<div className="space-y-4">
|
||||
<div className="relative aspect-square overflow-hidden rounded-lg border">
|
||||
<OptimizedImage
|
||||
src={product.images[0] || 'https://images.pexels.com/photos/3683107/pexels-photo-3683107.jpeg'}
|
||||
alt={product.name}
|
||||
width={400}
|
||||
height={400}
|
||||
className="w-full h-full object-contain"
|
||||
quality={80}
|
||||
/>
|
||||
{product.discount > 0 && (
|
||||
<Badge className="absolute top-4 left-4 bg-red-500 hover:bg-red-600 text-white font-semibold">
|
||||
{product.discount}% OFF
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional Images if available */}
|
||||
{product.images.length > 1 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{product.images.slice(1, 5).map((image, idx) => (
|
||||
<div key={idx} className="aspect-square rounded border overflow-hidden">
|
||||
<OptimizedImage
|
||||
src={image}
|
||||
alt={`${product.name} ${idx + 2}`}
|
||||
width={100}
|
||||
height={100}
|
||||
className="w-full h-full object-contain"
|
||||
quality={60}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Product Details</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{product.description || 'Premium quality product from Padmaaja Rasooi Pvt. Ltd.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Category and Brand */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">Category</span>
|
||||
<p className="font-semibold">{product.category?.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">Brand</span>
|
||||
<p className="font-semibold">{product.brand || 'Padmaaja Rasooi'}</p>
|
||||
</div>
|
||||
{product.weight && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">Weight</span>
|
||||
<p className="font-semibold">{product.weight}</p>
|
||||
</div>
|
||||
)}
|
||||
{product.origin && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">Origin</span>
|
||||
<p className="font-semibold">{product.origin}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
{product.discount > 0 ? (
|
||||
<>
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
₹{getDiscountedPrice(product.price, product.discount).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-lg text-gray-500 line-through">
|
||||
₹{product.price.toFixed(2)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
₹{product.price.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per kg price in quick view */}
|
||||
{getPerKgPrice(product.price, product.weight, product.discount) && (
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
<span className="font-medium">Per kg: </span>
|
||||
₹{getPerKgPrice(product.price, product.weight, product.discount)}/kg
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stock Status */}
|
||||
<div className="mb-4">
|
||||
<span className="text-sm font-medium text-gray-500">Availability: </span>
|
||||
<span className={`font-semibold ${product.stock > 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{product.stock > 0 ? `In Stock (${product.stock} available)` : 'Out of Stock'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={product.stock === 0}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
{product.stock === 0 ? 'Out of Stock' : 'Add to Cart'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsQuickViewOpen(false)
|
||||
router.push(`/products/${product.slug}`)
|
||||
}}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
View Full Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Rating in Quick View */}
|
||||
<div className="flex items-center space-x-2 pt-4 border-t">
|
||||
<div className="flex items-center">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-4 w-4 ${
|
||||
reviewStats.totalReviews > 0 && i < Math.round(reviewStats.averageRating)
|
||||
? 'text-yellow-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{reviewStats.totalReviews > 0
|
||||
? `${reviewStats.averageRating} (${reviewStats.totalReviews} reviews)`
|
||||
: 'No reviews yet'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</motion.div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
575
components/RecipeDialog.tsx
Normal file
575
components/RecipeDialog.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Recipe } from '@/lib/recipe-data'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
||||
import {
|
||||
Clock,
|
||||
Users,
|
||||
ChefHat,
|
||||
Lightbulb,
|
||||
X,
|
||||
Heart,
|
||||
Share2,
|
||||
Printer,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
PlayCircle,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
Timer
|
||||
} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface RecipeDialogProps {
|
||||
recipe: Recipe | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function RecipeDialog({ recipe, isOpen, onClose }: RecipeDialogProps) {
|
||||
const [activeStep, setActiveStep] = useState(0)
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set())
|
||||
const [checkedIngredients, setCheckedIngredients] = useState<Set<number>>(new Set())
|
||||
const [isTimerRunning, setIsTimerRunning] = useState(false)
|
||||
const [timerTime, setTimerTime] = useState(0)
|
||||
const [isFavorited, setIsFavorited] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'ingredients' | 'instructions' | 'tips'>('overview')
|
||||
|
||||
// Timer functionality
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout
|
||||
if (isTimerRunning && timerTime > 0) {
|
||||
interval = setInterval(() => {
|
||||
setTimerTime(time => {
|
||||
if (time <= 1) {
|
||||
setIsTimerRunning(false)
|
||||
// Could add notification here
|
||||
return 0
|
||||
}
|
||||
return time - 1
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
return () => clearInterval(interval)
|
||||
}, [isTimerRunning, timerTime])
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setActiveStep(0)
|
||||
setCompletedSteps(new Set())
|
||||
setCheckedIngredients(new Set())
|
||||
setIsTimerRunning(false)
|
||||
setTimerTime(0)
|
||||
setActiveTab('overview')
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
if (!recipe) return null
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'Easy': return 'bg-green-100 text-green-800 border-green-200'
|
||||
case 'Medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200'
|
||||
case 'Hard': return 'bg-red-100 text-red-800 border-red-200'
|
||||
default: return 'bg-gray-100 text-gray-800 border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
const toggleIngredientCheck = (index: number) => {
|
||||
const newChecked = new Set(checkedIngredients)
|
||||
if (newChecked.has(index)) {
|
||||
newChecked.delete(index)
|
||||
} else {
|
||||
newChecked.add(index)
|
||||
}
|
||||
setCheckedIngredients(newChecked)
|
||||
}
|
||||
|
||||
const toggleStepComplete = (index: number) => {
|
||||
const newCompleted = new Set(completedSteps)
|
||||
if (newCompleted.has(index)) {
|
||||
newCompleted.delete(index)
|
||||
} else {
|
||||
newCompleted.add(index)
|
||||
}
|
||||
setCompletedSteps(newCompleted)
|
||||
}
|
||||
|
||||
const startTimer = (minutes: number) => {
|
||||
setTimerTime(minutes * 60)
|
||||
setIsTimerRunning(true)
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const progress = recipe.instructions.length > 0 ? (completedSteps.size / recipe.instructions.length) * 100 : 0
|
||||
const ingredientProgress = recipe.ingredients.length > 0 ? (checkedIngredients.size / recipe.ingredients.length) * 100 : 0
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="w-screen h-screen max-w-none max-h-none p-0 rounded-none border-0 bg-white overflow-hidden [&>button]:hidden">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>{recipe.name} - Recipe Details</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{/* Hero Image */}
|
||||
<div className="relative h-48 sm:h-56 lg:h-64 overflow-hidden">
|
||||
<Image
|
||||
src={recipe.image}
|
||||
alt={recipe.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" />
|
||||
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 bg-black/20 backdrop-blur-sm text-white hover:bg-black/40 rounded-full w-10 h-10"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="absolute top-4 left-4 flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsFavorited(!isFavorited)}
|
||||
className={cn(
|
||||
"bg-black/20 backdrop-blur-sm rounded-full w-10 h-10 transition-colors",
|
||||
isFavorited ? "text-red-500 hover:bg-red-500/20" : "text-white hover:bg-black/40"
|
||||
)}
|
||||
>
|
||||
<Heart className={cn("h-5 w-5", isFavorited && "fill-current")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-black/20 backdrop-blur-sm text-white hover:bg-black/40 rounded-full w-10 h-10"
|
||||
>
|
||||
<Share2 className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-black/20 backdrop-blur-sm text-white hover:bg-black/40 rounded-full w-10 h-10"
|
||||
>
|
||||
<Printer className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Title Overlay */}
|
||||
<div className="absolute bottom-4 left-4 right-4">
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-white mb-1 drop-shadow-lg">
|
||||
{recipe.name}
|
||||
</h1>
|
||||
<p className="text-white/90 text-base sm:text-lg drop-shadow-lg max-w-2xl line-clamp-2">
|
||||
{recipe.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipe Meta Info */}
|
||||
<div className="bg-white border-b border-gray-200 p-4 flex-shrink-0">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-wrap gap-4 items-center justify-between mb-3">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-amber-600" />
|
||||
<span className="text-sm font-medium">{recipe.cookTime}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm font-medium">{recipe.servings} servings</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChefHat className="w-4 h-4 text-purple-600" />
|
||||
<Badge className={getDifficultyColor(recipe.difficulty)}>
|
||||
{recipe.difficulty}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-orange-700 border-orange-200 text-xs">
|
||||
{recipe.category}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
{timerTime > 0 && (
|
||||
<div className="flex items-center gap-2 bg-orange-50 px-3 py-1.5 rounded-lg border border-orange-200">
|
||||
<Timer className="w-4 h-4 text-orange-600" />
|
||||
<span className="font-mono text-sm font-medium text-orange-800">
|
||||
{formatTime(timerTime)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsTimerRunning(!isTimerRunning)}
|
||||
className="p-1 h-auto"
|
||||
>
|
||||
{isTimerRunning ? <Pause className="w-3 h-3" /> : <PlayCircle className="w-3 h-3" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTimerTime(0)}
|
||||
className="p-1 h-auto"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bars */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-gray-600 mb-1">
|
||||
<span>Ingredients checked</span>
|
||||
<span>{checkedIngredients.size}/{recipe.ingredients.length}</span>
|
||||
</div>
|
||||
<Progress value={ingredientProgress} className="h-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-gray-600 mb-1">
|
||||
<span>Steps completed</span>
|
||||
<span>{completedSteps.size}/{recipe.instructions.length}</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="bg-white border-b border-gray-200 flex-shrink-0">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<nav className="flex space-x-6">
|
||||
{[
|
||||
{ id: 'overview', label: 'Overview', icon: ChefHat },
|
||||
{ id: 'ingredients', label: 'Ingredients', icon: Check },
|
||||
{ id: 'instructions', label: 'Instructions', icon: PlayCircle },
|
||||
{ id: 'tips', label: 'Tips', icon: Lightbulb }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-3 px-1 border-b-2 font-medium text-sm transition-colors",
|
||||
activeTab === tab.id
|
||||
? "border-orange-500 text-orange-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-y-auto recipe-dialog-scroll">
|
||||
<div className="max-w-7xl mx-auto p-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'overview' && (
|
||||
<motion.div
|
||||
key="overview"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Nutrition Info */}
|
||||
{recipe.nutritionInfo && (
|
||||
<div className="bg-gradient-to-r from-green-50 to-blue-50 rounded-xl p-6 border border-green-100">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm font-bold">📊</span>
|
||||
</div>
|
||||
Nutrition Information (per serving)
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div className="text-center bg-white rounded-lg p-4 shadow-sm">
|
||||
<div className="text-2xl font-bold text-orange-600">{recipe.nutritionInfo.calories}</div>
|
||||
<div className="text-gray-600 font-medium">Calories</div>
|
||||
</div>
|
||||
<div className="text-center bg-white rounded-lg p-4 shadow-sm">
|
||||
<div className="text-2xl font-bold text-blue-600">{recipe.nutritionInfo.protein}</div>
|
||||
<div className="text-gray-600 font-medium">Protein</div>
|
||||
</div>
|
||||
<div className="text-center bg-white rounded-lg p-4 shadow-sm">
|
||||
<div className="text-2xl font-bold text-green-600">{recipe.nutritionInfo.carbs}</div>
|
||||
<div className="text-gray-600 font-medium">Carbs</div>
|
||||
</div>
|
||||
<div className="text-center bg-white rounded-lg p-4 shadow-sm">
|
||||
<div className="text-2xl font-bold text-purple-600">{recipe.nutritionInfo.fat}</div>
|
||||
<div className="text-gray-600 font-medium">Fat</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startTimer(5)}
|
||||
className="h-20 flex flex-col gap-2 hover:bg-orange-50 hover:border-orange-200"
|
||||
>
|
||||
<Timer className="w-6 h-6 text-orange-600" />
|
||||
<span className="text-sm">5 min timer</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startTimer(10)}
|
||||
className="h-20 flex flex-col gap-2 hover:bg-orange-50 hover:border-orange-200"
|
||||
>
|
||||
<Timer className="w-6 h-6 text-orange-600" />
|
||||
<span className="text-sm">10 min timer</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startTimer(15)}
|
||||
className="h-20 flex flex-col gap-2 hover:bg-orange-50 hover:border-orange-200"
|
||||
>
|
||||
<Timer className="w-6 h-6 text-orange-600" />
|
||||
<span className="text-sm">15 min timer</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setActiveTab('ingredients')}
|
||||
className="h-20 flex flex-col gap-2 hover:bg-green-50 hover:border-green-200"
|
||||
>
|
||||
<Check className="w-6 h-6 text-green-600" />
|
||||
<span className="text-sm">Start cooking</span>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'ingredients' && (
|
||||
<motion.div
|
||||
key="ingredients"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-green-50 p-6 border-b border-green-100">
|
||||
<h3 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-lg">🥘</span>
|
||||
</div>
|
||||
Ingredients
|
||||
</h3>
|
||||
<p className="text-gray-600 mt-2">Check off ingredients as you gather them</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{recipe.ingredients.map((ingredient, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className={cn(
|
||||
"flex items-center gap-4 p-4 rounded-lg border-2 transition-all cursor-pointer hover:shadow-md",
|
||||
checkedIngredients.has(index)
|
||||
? "bg-green-50 border-green-200 text-green-800"
|
||||
: "bg-white border-gray-200 hover:border-gray-300"
|
||||
)}
|
||||
onClick={() => toggleIngredientCheck(index)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all",
|
||||
checkedIngredients.has(index)
|
||||
? "bg-green-500 border-green-500"
|
||||
: "border-gray-300"
|
||||
)}>
|
||||
{checkedIngredients.has(index) && (
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-lg leading-relaxed flex-1",
|
||||
checkedIngredients.has(index) && "line-through"
|
||||
)}>
|
||||
{ingredient}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'instructions' && (
|
||||
<motion.div
|
||||
key="instructions"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-blue-50 p-6 border-b border-blue-100">
|
||||
<h3 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-lg">📝</span>
|
||||
</div>
|
||||
Cooking Instructions
|
||||
</h3>
|
||||
<p className="text-gray-600 mt-2">Follow these steps to create your delicious meal</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-6">
|
||||
{recipe.instructions.map((instruction, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className={cn(
|
||||
"flex gap-6 p-6 rounded-xl border-2 transition-all",
|
||||
completedSteps.has(index)
|
||||
? "bg-green-50 border-green-200"
|
||||
: activeStep === index
|
||||
? "bg-blue-50 border-blue-200 shadow-lg"
|
||||
: "bg-white border-gray-200"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<button
|
||||
onClick={() => toggleStepComplete(index)}
|
||||
className={cn(
|
||||
"w-12 h-12 rounded-full font-bold text-lg transition-all flex items-center justify-center",
|
||||
completedSteps.has(index)
|
||||
? "bg-green-500 text-white"
|
||||
: activeStep === index
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 text-gray-600 hover:bg-gray-300"
|
||||
)}
|
||||
>
|
||||
{completedSteps.has(index) ? (
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setActiveStep(index)}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
activeStep === index ? "text-blue-600" : "text-gray-500"
|
||||
)}
|
||||
>
|
||||
{activeStep === index ? "Current" : "Go to"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={cn(
|
||||
"text-lg leading-relaxed",
|
||||
completedSteps.has(index) && "line-through text-gray-500"
|
||||
)}>
|
||||
{instruction}
|
||||
</p>
|
||||
{index === activeStep && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => startTimer(5)}
|
||||
variant="outline"
|
||||
className="text-orange-600 border-orange-200 hover:bg-orange-50"
|
||||
>
|
||||
<Timer className="w-4 h-4 mr-1" />
|
||||
5 min
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => startTimer(10)}
|
||||
variant="outline"
|
||||
className="text-orange-600 border-orange-200 hover:bg-orange-50"
|
||||
>
|
||||
<Timer className="w-4 h-4 mr-1" />
|
||||
10 min
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tips' && recipe.tips && recipe.tips.length > 0 && (
|
||||
<motion.div
|
||||
key="tips"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="bg-gradient-to-r from-yellow-50 to-orange-50 rounded-xl border border-yellow-200 overflow-hidden">
|
||||
<div className="bg-yellow-100 p-6 border-b border-yellow-200">
|
||||
<h3 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<Lightbulb className="w-8 h-8 text-yellow-600" />
|
||||
Chef's Tips & Tricks
|
||||
</h3>
|
||||
<p className="text-gray-600 mt-2">Professional insights to elevate your cooking</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{recipe.tips.map((tip, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="flex items-start gap-4 p-4 bg-white rounded-lg border border-yellow-200 shadow-sm"
|
||||
>
|
||||
<span className="text-2xl">💡</span>
|
||||
<p className="text-lg leading-relaxed text-gray-700 flex-1">{tip}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
137
components/SEO.tsx
Normal file
137
components/SEO.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import Head from 'next/head'
|
||||
|
||||
interface SEOProps {
|
||||
title?: string
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
image?: string
|
||||
url?: string
|
||||
type?: 'website' | 'article' | 'product'
|
||||
publishedTime?: string
|
||||
modifiedTime?: string
|
||||
author?: string
|
||||
siteName?: string
|
||||
locale?: string
|
||||
}
|
||||
|
||||
export default function SEO({
|
||||
title = 'Padmaaja Rasooi - Premium Basmati & Sella Rice | Aged Quality Rice',
|
||||
description = 'Experience authentic aged Basmati 1121 and premium Sella rice from Padmaaja Rasooi. Direct from Punjab & Haryana rice belt with traditional aging process.',
|
||||
keywords = ['padmaaja rasooi', 'basmati rice', 'aged basmati', 'sella rice', 'premium rice', 'rice export', 'kashmina rice', '1121 basmati'],
|
||||
image = '/images/og-image.png',
|
||||
url = '',
|
||||
type = 'website',
|
||||
publishedTime,
|
||||
modifiedTime,
|
||||
author = 'Padmaaja Rasooi Team',
|
||||
siteName = 'Padmaaja Rasooi',
|
||||
locale = 'en_US'
|
||||
}: SEOProps) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_URL || 'https://padmaajarasooi.com'
|
||||
const fullUrl = url ? `${baseUrl}${url}` : baseUrl
|
||||
const fullImageUrl = image.startsWith('http') ? image : `${baseUrl}${image}`
|
||||
|
||||
return (
|
||||
<Head>
|
||||
{/* Basic Meta Tags */}
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="keywords" content={keywords.join(', ')} />
|
||||
<meta name="author" content={author} />
|
||||
<link rel="canonical" href={fullUrl} />
|
||||
|
||||
{/* Open Graph Meta Tags */}
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={fullImageUrl} />
|
||||
<meta property="og:url" content={fullUrl} />
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:locale" content={locale} />
|
||||
|
||||
{/* Article specific */}
|
||||
{type === 'article' && publishedTime && (
|
||||
<meta property="article:published_time" content={publishedTime} />
|
||||
)}
|
||||
{type === 'article' && modifiedTime && (
|
||||
<meta property="article:modified_time" content={modifiedTime} />
|
||||
)}
|
||||
{type === 'article' && author && (
|
||||
<meta property="article:author" content={author} />
|
||||
)}
|
||||
|
||||
{/* Twitter Card Meta Tags */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={fullImageUrl} />
|
||||
<meta name="twitter:site" content="@padmaajarasooi" />
|
||||
<meta name="twitter:creator" content="@padmaajarasooi" />
|
||||
|
||||
{/* Additional SEO Meta Tags */}
|
||||
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
|
||||
<meta name="googlebot" content="index, follow" />
|
||||
<meta name="bingbot" content="index, follow" />
|
||||
|
||||
{/* Mobile Optimization */}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
|
||||
{/* Language and Region */}
|
||||
<meta httpEquiv="content-language" content="en" />
|
||||
<meta name="geo.region" content="IN" />
|
||||
<meta name="geo.country" content="India" />
|
||||
|
||||
{/* Schema.org for Google */}
|
||||
<meta itemProp="name" content={title} />
|
||||
<meta itemProp="description" content={description} />
|
||||
<meta itemProp="image" content={fullImageUrl} />
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
|
||||
// Pre-defined SEO configurations for common pages
|
||||
export const SEOConfigs = {
|
||||
home: {
|
||||
title: 'Padmaaja Rasooi - Premium Basmati & Sella Rice | Aged Quality Rice',
|
||||
description: 'Experience authentic aged Basmati 1121 and premium Sella rice from Padmaaja Rasooi. Direct from Punjab & Haryana rice belt with traditional aging process.',
|
||||
keywords: ['padmaaja rasooi', 'basmati rice', 'aged basmati', 'sella rice', 'premium rice', 'rice export', 'kashmina rice', '1121 basmati', 'rice trade'],
|
||||
url: '/'
|
||||
},
|
||||
products: {
|
||||
title: 'Premium Rice Products | Padmaaja Rasooi',
|
||||
description: 'Explore our extensive collection of premium quality rice varieties. From organic basmati to traditional rice, find the perfect rice for your needs.',
|
||||
keywords: ['premium rice', 'organic rice', 'basmati rice', 'rice varieties', 'quality rice', 'rice products', 'padmaaja rasooi'],
|
||||
url: '/products'
|
||||
},
|
||||
about: {
|
||||
title: 'About Us | Padmaaja Rasooi - Quality Rice Heritage',
|
||||
description: 'Learn about Padmaaja Rasooi\'s commitment to quality rice products and sustainable farming practices. Discover our story and values.',
|
||||
keywords: ['about padmaaja rasooi', 'rice company', 'quality standards', 'sustainable farming', 'rice heritage'],
|
||||
url: '/about'
|
||||
},
|
||||
contact: {
|
||||
title: 'Contact Us | Padmaaja Rasooi',
|
||||
description: 'Get in touch with Padmaaja Rasooi for premium rice products, wholesale inquiries, or any questions. We\'re here to help.',
|
||||
keywords: ['contact padmaaja rasooi', 'rice supplier contact', 'wholesale inquiry', 'customer support'],
|
||||
url: '/contact'
|
||||
},
|
||||
privacyPolicy: {
|
||||
title: 'Privacy Policy | Padmaaja Rasooi',
|
||||
description: 'Read our privacy policy to understand how Padmaaja Rasooi protects and handles your personal information.',
|
||||
keywords: ['privacy policy', 'data protection', 'personal information', 'padmaaja rasooi'],
|
||||
url: '/legal/privacy-policy'
|
||||
},
|
||||
termsOfService: {
|
||||
title: 'Terms of Service | Padmaaja Rasooi',
|
||||
description: 'Read our terms of service for using Padmaaja Rasooi products and services.',
|
||||
keywords: ['terms of service', 'legal terms', 'service conditions', 'padmaaja rasooi'],
|
||||
url: '/legal/terms-of-service'
|
||||
},
|
||||
refundPolicy: {
|
||||
title: 'Refund Policy | Padmaaja Rasooi',
|
||||
description: 'Learn about our refund and return policy for Padmaaja Rasooi rice products and services.',
|
||||
keywords: ['refund policy', 'return policy', 'money back guarantee', 'padmaaja rasooi'],
|
||||
url: '/legal/refund-policy'
|
||||
}
|
||||
}
|
||||
44
components/StructuredData.tsx
Normal file
44
components/StructuredData.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { Thing, WithContext } from 'schema-dts'
|
||||
|
||||
interface StructuredDataProps {
|
||||
data: WithContext<Thing> | any
|
||||
id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* StructuredData component for rendering JSON-LD
|
||||
* Sanitizes output to prevent XSS attacks by replacing < with \u003c
|
||||
*/
|
||||
export default function StructuredData({ data, id }: StructuredDataProps) {
|
||||
return (
|
||||
<script
|
||||
id={id || 'structured-data'}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(data).replace(/</g, '\\u003c')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Utility component for multiple structured data objects
|
||||
interface MultipleStructuredDataProps {
|
||||
dataArray: Array<WithContext<Thing> | any>
|
||||
idPrefix?: string
|
||||
}
|
||||
|
||||
export function MultipleStructuredData({ dataArray, idPrefix = 'structured-data' }: MultipleStructuredDataProps) {
|
||||
return (
|
||||
<>
|
||||
{dataArray.map((data, index) => (
|
||||
<StructuredData
|
||||
key={`${idPrefix}-${index}`}
|
||||
id={`${idPrefix}-${index}`}
|
||||
data={data}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
182
components/UpdateNotification.tsx
Normal file
182
components/UpdateNotification.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { RefreshCw, X, Download } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { registerUpdateHandler, forceReload } from '@/lib/cache-manager'
|
||||
|
||||
interface UpdateNotificationProps {
|
||||
autoShow?: boolean
|
||||
position?: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
export default function UpdateNotification({
|
||||
autoShow = true,
|
||||
position = 'bottom'
|
||||
}: UpdateNotificationProps) {
|
||||
const [showUpdate, setShowUpdate] = useState(false)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoShow) {
|
||||
// Register for update notifications
|
||||
registerUpdateHandler(() => {
|
||||
setShowUpdate(true)
|
||||
})
|
||||
}
|
||||
}, [autoShow])
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setIsUpdating(true)
|
||||
try {
|
||||
// Force reload with cache clear
|
||||
forceReload()
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error)
|
||||
setIsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowUpdate(false)
|
||||
}
|
||||
|
||||
const positionStyles = position === 'top'
|
||||
? 'top-4 left-4 right-4'
|
||||
: 'bottom-4 left-4 right-4'
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{showUpdate && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: position === 'top' ? -100 : 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: position === 'top' ? -100 : 100 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className={`fixed ${positionStyles} z-[9999] max-w-md mx-auto`}
|
||||
>
|
||||
<Card className="shadow-2xl border-0 bg-gradient-to-r from-emerald-500 to-blue-500 text-white overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="relative">
|
||||
{/* Background pattern */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-600/20 to-blue-600/20" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-white/20 rounded-lg backdrop-blur-sm">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">
|
||||
Update Available!
|
||||
</h3>
|
||||
<Badge variant="secondary" className="bg-white/20 text-white text-xs mt-1">
|
||||
Version 2.0
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
className="text-white hover:bg-white/20 p-1 h-auto"
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-white/90 text-sm mb-4 leading-relaxed">
|
||||
A new version is available with improved features and bug fixes.
|
||||
Refresh to get the latest updates.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
disabled={isUpdating}
|
||||
size="sm"
|
||||
className="bg-white text-emerald-600 hover:bg-white/90 flex-1"
|
||||
>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
</motion.div>
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh Now
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
disabled={isUpdating}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animated shimmer effect */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent"
|
||||
animate={{
|
||||
x: ['-100%', '100%']
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: 'linear'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// Development component to manually trigger updates
|
||||
export function DevUpdateTrigger() {
|
||||
const [showUpdate, setShowUpdate] = useState(false)
|
||||
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-20 right-4 z-[9999]">
|
||||
<Button
|
||||
onClick={() => setShowUpdate(!showUpdate)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-yellow-100 border-yellow-300 text-yellow-700 hover:bg-yellow-200"
|
||||
>
|
||||
🚧 Test Update
|
||||
</Button>
|
||||
|
||||
{showUpdate && (
|
||||
<UpdateNotification autoShow={false} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
components/WholesalePricing.tsx
Normal file
167
components/WholesalePricing.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Gift, ShoppingCart, Users, TrendingDown } from 'lucide-react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
interface WholesalePricingProps {
|
||||
productId: string
|
||||
originalPrice: number
|
||||
quantity: number
|
||||
}
|
||||
|
||||
interface PricingData {
|
||||
originalPrice: number
|
||||
finalPrice: number
|
||||
discount: number
|
||||
discountPercentage: number
|
||||
isWholesalePrice: boolean
|
||||
}
|
||||
|
||||
export default function WholesalePricing({
|
||||
productId,
|
||||
originalPrice,
|
||||
quantity
|
||||
}: WholesalePricingProps) {
|
||||
const { data: session } = useSession()
|
||||
const [pricingData, setPricingData] = useState<PricingData | null>(null)
|
||||
const [isWholesaler, setIsWholesaler] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPricing = async () => {
|
||||
if (!session?.user) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/pricing/wholesale', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
quantity
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setPricingData(data.pricing)
|
||||
setIsWholesaler(data.isWholesaler)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pricing:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (session?.user && productId && quantity > 0) {
|
||||
fetchPricing()
|
||||
}
|
||||
}, [session, productId, quantity])
|
||||
|
||||
// Don't show if user is not logged in
|
||||
if (!session?.user) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Show wholesaler benefits if user is wholesaler
|
||||
if (isWholesaler) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Badge className="bg-blue-100 text-blue-800 px-3 py-1">
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
Wholesaler Account
|
||||
</Badge>
|
||||
|
||||
{pricingData && quantity >= 10 && (
|
||||
<Card className="border-green-200 bg-green-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Gift className="h-5 w-5 text-green-600" />
|
||||
<span className="font-medium text-green-800">
|
||||
Wholesale Discount Applied!
|
||||
</span>
|
||||
</div>
|
||||
<Badge className="bg-green-100 text-green-800">
|
||||
25% OFF
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Original Price:</span>
|
||||
<span className="line-through text-gray-500">
|
||||
₹{pricingData.originalPrice.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-medium">
|
||||
<span>Wholesale Price:</span>
|
||||
<span className="text-green-600">
|
||||
₹{pricingData.finalPrice.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>You Save:</span>
|
||||
<span className="text-green-600">
|
||||
₹{pricingData.discount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{quantity < 10 && (
|
||||
<Card className="border-yellow-200 bg-yellow-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShoppingCart className="h-5 w-5 text-yellow-600" />
|
||||
<span className="font-medium text-yellow-800">
|
||||
Add {10 - quantity} more items for 25% wholesale discount
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
Minimum 10 items required for bulk pricing
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show wholesaler invitation if user is not a wholesaler
|
||||
return (
|
||||
<Card className="border-blue-200 bg-blue-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<TrendingDown className="h-5 w-5 text-blue-600" />
|
||||
<span className="font-medium text-blue-800">
|
||||
Want Wholesale Prices?
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-700 mb-3">
|
||||
Join our wholesaler program and get 25% off on bulk orders!
|
||||
</p>
|
||||
<div className="space-y-1 text-xs text-blue-600">
|
||||
<div>• 25% discount on orders of 10+ items</div>
|
||||
<div>• Dedicated account manager</div>
|
||||
<div>• Priority support</div>
|
||||
<div>• Flexible payment terms</div>
|
||||
</div>
|
||||
<a
|
||||
href="/wholesaler"
|
||||
className="inline-block mt-3 text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Register as Wholesaler →
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
88
components/admin/AdminHeader.tsx
Normal file
88
components/admin/AdminHeader.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Bell, Settings, LogOut, Eye } from 'lucide-react'
|
||||
|
||||
interface AdminHeaderProps {
|
||||
user: {
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
image?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminHeader({ user }: AdminHeaderProps) {
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="flex h-16 items-center justify-between px-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/admin" className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">M</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900">Padmaaja Rasooi Admin</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/" className="flex items-center">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Site
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm">
|
||||
<Bell className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.image || ''} alt={user.name || ''} />
|
||||
<AvatarFallback>
|
||||
{user.name?.[0] || user.email?.[0] || 'A'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin/settings" className="flex items-center">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => signOut()}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
90
components/admin/AdminSidebar.tsx
Normal file
90
components/admin/AdminSidebar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
Settings,
|
||||
BarChart3,
|
||||
CreditCard,
|
||||
Tag,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Mail,
|
||||
Star
|
||||
} from 'lucide-react'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ name: 'Users', href: '/admin/users', icon: Users },
|
||||
{ name: 'Products', href: '/admin/products', icon: Package },
|
||||
{ name: 'Categories', href: '/admin/categories', icon: Tag },
|
||||
{ name: 'Orders', href: '/admin/orders', icon: ShoppingCart },
|
||||
{ name: 'Reviews', href: '/admin/reviews', icon: Star },
|
||||
{ name: 'Form Responses', href: '/admin/forms', icon: Mail },
|
||||
{ name: 'Commissions', href: '/admin/commissions', icon: DollarSign },
|
||||
{ name: 'Payouts', href: '/admin/payouts', icon: CreditCard },
|
||||
{ name: 'Analytics', href: '/admin/analytics', icon: BarChart3 },
|
||||
{ name: 'Settings', href: '/admin/settings', icon: Settings },
|
||||
]
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"bg-white border-r border-gray-200 transition-all duration-300",
|
||||
collapsed ? "w-16" : "w-64"
|
||||
)}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
{!collapsed && (
|
||||
<h2 className="text-lg font-semibold text-gray-900">Admin Panel</h2>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 p-4">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
||||
)}
|
||||
title={collapsed ? item.name : undefined}
|
||||
>
|
||||
<item.icon className={cn("h-5 w-5", collapsed ? "mx-auto" : "mr-3")} />
|
||||
{!collapsed && <span>{item.name}</span>}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
243
components/admin/CategoryFormDialog.tsx
Normal file
243
components/admin/CategoryFormDialog.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'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 { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Save, Upload, X, Plus, Edit } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface Category {
|
||||
id?: string
|
||||
name: string
|
||||
description: string
|
||||
image: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface CategoryFormDialogProps {
|
||||
category?: Category
|
||||
onSuccess?: () => void
|
||||
trigger?: React.ReactNode
|
||||
mode?: 'create' | 'edit'
|
||||
}
|
||||
|
||||
export function CategoryFormDialog({
|
||||
category,
|
||||
onSuccess,
|
||||
trigger,
|
||||
mode = category ? 'edit' : 'create'
|
||||
}: CategoryFormDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [formData, setFormData] = useState<Category>({
|
||||
name: '',
|
||||
description: '',
|
||||
image: null,
|
||||
isActive: true,
|
||||
...category
|
||||
})
|
||||
|
||||
const handleInputChange = (field: keyof Category, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Mock image upload - replace with actual upload logic
|
||||
const imageUrl = URL.createObjectURL(file)
|
||||
setFormData(prev => ({ ...prev, image: imageUrl }))
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
setFormData(prev => ({ ...prev, image: null }))
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
image: null,
|
||||
isActive: true,
|
||||
...category
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const url = mode === 'edit' && category?.id
|
||||
? `/api/admin/categories/${category.id}`
|
||||
: '/api/admin/categories'
|
||||
|
||||
const method = mode === 'edit' ? 'PATCH' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to save category')
|
||||
}
|
||||
|
||||
toast.success(`Category ${mode === 'edit' ? 'updated' : 'created'} successfully`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
onSuccess?.()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || `Failed to ${mode === 'edit' ? 'update' : 'create'} category`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTrigger = mode === 'edit' ? (
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Category
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || defaultTrigger}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'edit' ? 'Edit Category' : 'Create New Category'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Category Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Describe this category..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="isActive">Active Status</Label>
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => handleInputChange('isActive', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{formData.isActive ? 'Category is visible to customers' : 'Category is hidden from customers'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category Image</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="image" className="cursor-pointer">
|
||||
<div className="flex items-center space-x-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg hover:border-gray-400">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span>Upload Image</span>
|
||||
</div>
|
||||
</Label>
|
||||
<Input
|
||||
id="image"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.image && (
|
||||
<div className="relative group">
|
||||
<Image
|
||||
src={formData.image}
|
||||
alt="Category image"
|
||||
width={200}
|
||||
height={200}
|
||||
className="rounded-lg object-cover w-full h-48"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={removeImage}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{loading ? 'Saving...' : mode === 'edit' ? 'Update Category' : 'Create Category'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
537
components/admin/MediaSelector.tsx
Normal file
537
components/admin/MediaSelector.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Search,
|
||||
Upload,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
File,
|
||||
FolderPlus,
|
||||
Loader2,
|
||||
Check,
|
||||
X,
|
||||
Eye
|
||||
} from 'lucide-react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { toast } from 'sonner'
|
||||
import Image from 'next/image'
|
||||
|
||||
// Helper function to determine content type from URL
|
||||
const getContentTypeFromUrl = (url: string): string | null => {
|
||||
// Try to extract extension from the pathname, looking for common image extensions
|
||||
// even if they're not at the very end (due to timestamps or other suffixes)
|
||||
const extensionMatch = url.match(/\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mp3|wav|pdf|doc|docx|txt)(?:-\d+)?(?:\?.*)?$/i)
|
||||
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : null
|
||||
|
||||
if (!extension) {
|
||||
// Fallback: check if the URL contains any image extension
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']
|
||||
for (const ext of imageExtensions) {
|
||||
if (url.toLowerCase().includes(`.${ext}`)) {
|
||||
return getExtensionMimeType(ext)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return getExtensionMimeType(extension)
|
||||
}
|
||||
|
||||
// Helper function to map extensions to MIME types
|
||||
const getExtensionMimeType = (extension: string): string | null => {
|
||||
const extensionMap: Record<string, string> = {
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'svg': 'image/svg+xml',
|
||||
'mp4': 'video/mp4',
|
||||
'webm': 'video/webm',
|
||||
'mp3': 'audio/mpeg',
|
||||
'wav': 'audio/wav',
|
||||
'pdf': 'application/pdf',
|
||||
'doc': 'application/msword',
|
||||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'txt': 'text/plain'
|
||||
}
|
||||
return extensionMap[extension] || null
|
||||
}
|
||||
|
||||
export interface MediaFile {
|
||||
id: string
|
||||
name: string
|
||||
type: 'file' | 'folder'
|
||||
size?: number
|
||||
mimeType?: string
|
||||
url?: string
|
||||
path: string
|
||||
createdAt: string
|
||||
uploadedBy: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MediaSelectorProps {
|
||||
onSelect: (file: MediaFile) => void
|
||||
selectedUrl?: string
|
||||
allowedTypes?: string[] // e.g., ['image/*', 'video/*']
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MediaSelector({
|
||||
onSelect,
|
||||
selectedUrl,
|
||||
allowedTypes = ['image/*', 'video/*', 'audio/*'],
|
||||
children
|
||||
}: MediaSelectorProps) {
|
||||
console.log('MediaSelector component rendered')
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [files, setFiles] = useState<MediaFile[]>([])
|
||||
const [currentPath, setCurrentPath] = useState('/')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
|
||||
const fetchFiles = useCallback(async () => {
|
||||
console.log('fetchFiles called, isOpen:', isOpen, 'currentPath:', currentPath)
|
||||
|
||||
if (!isOpen) {
|
||||
console.log('Dialog not open, skipping fetch')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// Use our blob storage API to list files
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
// Use 'media' prefix for all requests for consistency with upload folder structure
|
||||
const prefix = 'media'
|
||||
console.log('Fetching files with prefix:', prefix)
|
||||
|
||||
searchParams.set('prefix', prefix)
|
||||
searchParams.set('limit', '100')
|
||||
|
||||
const apiUrl = `/api/upload/files?${searchParams.toString()}`
|
||||
console.log('Making API request to:', apiUrl)
|
||||
|
||||
const response = await fetch(apiUrl)
|
||||
console.log('API response status:', response.status)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
console.log('MediaSelector API response:', data) // Debug log
|
||||
if (data.success) {
|
||||
// Transform blob storage response to MediaFile format
|
||||
const blobs = data.data?.blobs || []
|
||||
console.log('Blobs found:', blobs) // Debug log
|
||||
const transformedFiles: MediaFile[] = blobs.map((blob: any) => {
|
||||
const mimeType = getContentTypeFromUrl(blob.url) || 'application/octet-stream'
|
||||
console.log('Transforming blob:', {
|
||||
url: blob.url,
|
||||
pathname: blob.pathname,
|
||||
detectedMimeType: mimeType
|
||||
})
|
||||
return {
|
||||
id: blob.pathname,
|
||||
name: blob.pathname.split('/').pop() || blob.pathname,
|
||||
type: 'file' as const,
|
||||
size: blob.size,
|
||||
mimeType,
|
||||
url: blob.url,
|
||||
path: blob.pathname,
|
||||
createdAt: blob.uploadedAt,
|
||||
uploadedBy: {
|
||||
id: 'system',
|
||||
name: 'System'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Transformed files:', transformedFiles)
|
||||
console.log('Allowed types:', allowedTypes)
|
||||
|
||||
// Filter files based on allowed types
|
||||
const filteredFiles = transformedFiles.filter((file: MediaFile) => {
|
||||
if (!file.mimeType) {
|
||||
console.log('File rejected - no mimeType:', file.name)
|
||||
return false
|
||||
}
|
||||
|
||||
const isAllowed = allowedTypes.some(type => {
|
||||
if (type.endsWith('/*')) {
|
||||
const matches = file.mimeType!.startsWith(type.replace('/*', '/'))
|
||||
console.log(`Checking ${file.mimeType} against ${type}: ${matches}`)
|
||||
return matches
|
||||
}
|
||||
const matches = file.mimeType === type
|
||||
console.log(`Checking ${file.mimeType} against ${type}: ${matches}`)
|
||||
return matches
|
||||
})
|
||||
|
||||
console.log(`File ${file.name} allowed: ${isAllowed}`)
|
||||
return isAllowed
|
||||
})
|
||||
|
||||
console.log('Filtered files:', filteredFiles)
|
||||
setFiles(filteredFiles)
|
||||
} else {
|
||||
console.error('API request failed:', data)
|
||||
setFiles([])
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch files, status:', response.status)
|
||||
setFiles([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching files:', error)
|
||||
toast.error('Failed to load files')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [currentPath, isOpen, allowedTypes])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('MediaSelector mounted, isOpen:', isOpen)
|
||||
fetchFiles()
|
||||
}, [fetchFiles, isOpen])
|
||||
|
||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
|
||||
acceptedFiles.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
// Set upload type and folder based on current path
|
||||
formData.append('type', 'general')
|
||||
if (currentPath !== '/') {
|
||||
formData.append('folder', currentPath.substring(1)) // Remove leading slash
|
||||
} else {
|
||||
formData.append('folder', 'media')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload/files', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
toast.success(`Uploaded ${data.data.length} file(s) successfully`)
|
||||
fetchFiles()
|
||||
} else {
|
||||
console.error('Upload failed with data:', data)
|
||||
throw new Error(data.message || 'Upload failed')
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.text()
|
||||
console.error('Upload failed with status:', response.status, 'Response:', errorData)
|
||||
throw new Error(`Upload failed with status ${response.status}: ${errorData}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toast.error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}, [currentPath, fetchFiles])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: true,
|
||||
accept: allowedTypes.reduce((acc, type) => {
|
||||
acc[type] = []
|
||||
return acc
|
||||
}, {} as Record<string, string[]>)
|
||||
})
|
||||
|
||||
const handleFileSelect = (file: MediaFile) => {
|
||||
if (file.type === 'folder') {
|
||||
setCurrentPath(currentPath === '/' ? `/${file.name}` : `${currentPath}/${file.name}`)
|
||||
setSelectedFile(null)
|
||||
setPreviewUrl(null)
|
||||
} else {
|
||||
setSelectedFile(file)
|
||||
setPreviewUrl(file.url || null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmSelection = () => {
|
||||
if (selectedFile) {
|
||||
onSelect(selectedFile)
|
||||
setIsOpen(false)
|
||||
setSelectedFile(null)
|
||||
setPreviewUrl(null)
|
||||
}
|
||||
}
|
||||
|
||||
const navigateUp = () => {
|
||||
const pathParts = currentPath.split('/').filter(p => p)
|
||||
if (pathParts.length > 0) {
|
||||
pathParts.pop()
|
||||
setCurrentPath(pathParts.length === 0 ? '/' : '/' + pathParts.join('/'))
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIcon = (file: MediaFile) => {
|
||||
if (file.type === 'folder') return <FolderPlus className="h-8 w-8 text-blue-500" />
|
||||
|
||||
if (!file.mimeType) return <File className="h-8 w-8 text-gray-500" />
|
||||
|
||||
if (file.mimeType.startsWith('image/')) return <ImageIcon className="h-8 w-8 text-green-500" />
|
||||
if (file.mimeType.startsWith('video/')) return <Video className="h-8 w-8 text-red-500" />
|
||||
if (file.mimeType.startsWith('audio/')) return <Music className="h-8 w-8 text-purple-500" />
|
||||
|
||||
return <File className="h-8 w-8 text-gray-500" />
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const filteredFiles = files.filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => {
|
||||
console.log('Dialog onOpenChange called with:', open)
|
||||
setIsOpen(open)
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-7xl max-h-[90vh] w-[95vw] p-0">
|
||||
<DialogHeader className="p-4 sm:p-6 pb-2 sm:pb-4">
|
||||
<DialogTitle className="text-lg sm:text-xl">Select Media</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-3 gap-4 p-4 sm:p-6 pt-0 h-[80vh] lg:h-[600px]">
|
||||
{/* File Browser */}
|
||||
<div className="lg:col-span-2 lg:border-r lg:pr-4 flex-1 lg:flex-none">
|
||||
<Tabs defaultValue="browse" className="h-full flex flex-col">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||
<TabsTrigger value="browse" className="text-sm">Browse Files</TabsTrigger>
|
||||
<TabsTrigger value="upload" className="text-sm">Upload New</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="browse" className="flex-1 flex flex-col space-y-3 sm:space-y-4">
|
||||
{/* Navigation */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
{currentPath !== '/' && (
|
||||
<Button variant="outline" size="sm" onClick={navigateUp} className="w-fit">
|
||||
← Back
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-xs sm:text-sm text-gray-600 break-all">
|
||||
Path: {currentPath}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search files..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Files Grid */}
|
||||
<div className="flex-1 overflow-y-auto h-auto max-h-[400px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-3 xl:grid-cols-4 gap-2 sm:gap-3">
|
||||
{filteredFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`relative p-2 sm:p-3 border rounded-lg cursor-pointer transition-all hover:shadow-md touch-manipulation ${
|
||||
selectedFile?.id === file.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => handleFileSelect(file)}
|
||||
>
|
||||
<div className="aspect-square bg-gray-100 rounded-md flex items-center justify-center mb-2 overflow-hidden">
|
||||
{file.mimeType?.startsWith('image/') && file.url ? (
|
||||
<Image
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
{getFileIcon(file)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
<p className="font-medium truncate" title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
{file.size && (
|
||||
<p className="text-gray-500 text-xs">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedFile?.id === file.id && (
|
||||
<div className="absolute top-1 right-1 sm:top-2 sm:right-2">
|
||||
<div className="bg-blue-500 text-white rounded-full p-1">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upload" className="flex-1">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`h-full min-h-[200px] border-2 border-dashed rounded-lg flex flex-col items-center justify-center transition-colors p-4 ${
|
||||
isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{uploading ? (
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 sm:h-12 sm:w-12 animate-spin text-blue-500 mx-auto mb-4" />
|
||||
<p className="text-blue-600 text-sm sm:text-base">Uploading files...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-sm sm:text-lg font-medium text-gray-900 mb-2">
|
||||
Drop files here or click to browse
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-gray-500">
|
||||
Supported: {allowedTypes.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Preview Panel */}
|
||||
<div className="flex flex-col min-h-[300px] lg:min-h-0">
|
||||
<h3 className="font-semibold mb-4 text-sm sm:text-base">Preview</h3>
|
||||
|
||||
{selectedFile && previewUrl ? (
|
||||
<div className="flex-1 flex flex-col space-y-3 sm:space-y-4">
|
||||
<div className="aspect-4/3 bg-gray-100 rounded-lg overflow-hidden flex items-center justify-center">
|
||||
{selectedFile.mimeType?.startsWith('image/') ? (
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt={selectedFile.name}
|
||||
width={200}
|
||||
height={200}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
) : selectedFile.mimeType?.startsWith('video/') ? (
|
||||
<video controls className="w-full h-full max-h-full">
|
||||
<source src={previewUrl} type={selectedFile.mimeType} />
|
||||
</video>
|
||||
) : selectedFile.mimeType?.startsWith('audio/') ? (
|
||||
<div className="w-full p-4">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Music className="h-12 w-12 sm:h-16 sm:w-16 text-purple-500" />
|
||||
</div>
|
||||
<audio controls className="w-full">
|
||||
<source src={previewUrl} type={selectedFile.mimeType} />
|
||||
</audio>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
{getFileIcon(selectedFile)}
|
||||
<p className="text-xs sm:text-sm text-gray-500 mt-2">No preview available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-sm break-all" title={selectedFile.name}>
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
{selectedFile.size && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(selectedFile.size)}
|
||||
</p>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedFile.mimeType}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-auto flex items-center justify-between gap-2">
|
||||
<Button
|
||||
onClick={handleConfirmSelection}
|
||||
className="w-full text-sm"
|
||||
disabled={!selectedFile}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Select This File
|
||||
</Button>
|
||||
{selectedUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onSelect({ url: '', name: '', id: '', type: 'file', path: '', createdAt: '', uploadedBy: { id: '', name: '' } })
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="w-full text-sm !m-0"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Remove Current
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<Eye className="h-8 w-8 sm:h-12 sm:w-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm sm:text-base">Select a file to preview</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
419
components/admin/ProductForm.tsx
Normal file
419
components/admin/ProductForm.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
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 { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ArrowLeft, Save, Upload, X, ImageIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import MediaSelector, { MediaFile } from '@/components/admin/MediaSelector'
|
||||
|
||||
interface Product {
|
||||
id?: string
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
discount: number
|
||||
images: string[]
|
||||
stock: number
|
||||
manageStock: boolean
|
||||
sku: string
|
||||
isActive: boolean
|
||||
categoryId: string
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ProductFormProps {
|
||||
product?: Product
|
||||
}
|
||||
|
||||
export function ProductForm({ product }: ProductFormProps) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(true)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [imageUrl, setImageUrl] = useState('')
|
||||
const [formData, setFormData] = useState<Product>({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
discount: 0,
|
||||
images: [],
|
||||
stock: 0,
|
||||
manageStock: true,
|
||||
sku: '',
|
||||
isActive: true,
|
||||
categoryId: '',
|
||||
...product
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
}, [])
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
setCategoriesLoading(true)
|
||||
const response = await fetch('/api/categories')
|
||||
const data = await response.json()
|
||||
// Handle the new API response structure
|
||||
setCategories(data.categories || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error)
|
||||
setCategories([]) // Fallback to empty array
|
||||
} finally {
|
||||
setCategoriesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof Product, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleAddImageUrl = () => {
|
||||
if (!imageUrl.trim()) {
|
||||
toast.error('Please enter a valid image URL')
|
||||
return
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
try {
|
||||
new URL(imageUrl)
|
||||
} catch {
|
||||
toast.error('Please enter a valid URL')
|
||||
return
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: [...prev.images, imageUrl.trim()]
|
||||
}))
|
||||
|
||||
setImageUrl('')
|
||||
toast.success('Image URL added')
|
||||
}
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: prev.images.filter((_, i) => i !== index)
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const url = product
|
||||
? `/api/admin/products/${product.id}`
|
||||
: '/api/admin/products'
|
||||
|
||||
const method = product ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save product')
|
||||
|
||||
toast.success(`Product ${product ? 'updated' : 'created'} successfully`)
|
||||
router.push('/admin/products')
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${product ? 'update' : 'create'} product`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/admin/products">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Products
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{loading ? 'Saving...' : 'Save Product'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Product Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Product Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="sku">SKU</Label>
|
||||
<Input
|
||||
id="sku"
|
||||
value={formData.sku}
|
||||
onChange={(e) => handleInputChange('sku', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<Select
|
||||
value={formData.categoryId}
|
||||
onValueChange={(value) => handleInputChange('categoryId', value)}
|
||||
disabled={categoriesLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={
|
||||
categoriesLoading
|
||||
? "Loading categories..."
|
||||
: categories.length === 0
|
||||
? "No categories available"
|
||||
: "Select category"
|
||||
} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!categoriesLoading && categories.length > 0 ? (
|
||||
categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Product Images</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Media Selector Section */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Product Images</Label>
|
||||
<MediaSelector
|
||||
allowedTypes={['image/*']}
|
||||
onSelect={(file: MediaFile) => {
|
||||
const imageUrl = file.url
|
||||
if (imageUrl && typeof imageUrl === 'string' && imageUrl.trim()) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: [...prev.images, imageUrl]
|
||||
}))
|
||||
toast.success('Image added successfully')
|
||||
} else {
|
||||
toast.error('Invalid file selected - no valid URL found')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg hover:border-gray-400 cursor-pointer">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
<span>Browse & Upload Images</span>
|
||||
</div>
|
||||
</MediaSelector>
|
||||
<p className="text-xs text-gray-500">
|
||||
Browse existing images or upload new ones. Supports JPG, PNG, WebP formats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* URL Input Section (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Add Image URL (Optional)</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
placeholder="https://example.com/image.jpg"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddImageUrl()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddImageUrl}
|
||||
disabled={!imageUrl.trim()}
|
||||
>
|
||||
Add URL
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Add images from external URLs (e.g., Unsplash, your CDN, etc.)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Image Preview Grid */}
|
||||
{formData.images.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Image Preview</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{formData.images.map((image, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<Image
|
||||
src={image}
|
||||
alt={`Product image ${index + 1}`}
|
||||
width={150}
|
||||
height={150}
|
||||
className="rounded-lg object-cover w-full h-32 border"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = '/placeholder-image.jpg'; // fallback image
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0"
|
||||
onClick={() => removeImage(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 rounded-b-lg truncate">
|
||||
{image.startsWith('/products/') ? 'Uploaded' : 'External URL'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.images.length === 0 && (
|
||||
<div className="text-center py-8 border-2 border-dashed border-gray-200 rounded-lg">
|
||||
<Upload className="h-8 w-8 mx-auto text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">No images added yet</p>
|
||||
<p className="text-xs text-gray-400">Upload files or add URLs to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pricing & Inventory</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="price">Price (₹)</Label>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.price}
|
||||
onChange={(e) => handleInputChange('price', parseFloat(e.target.value) || 0)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="discount">Discount (%)</Label>
|
||||
<Input
|
||||
id="discount"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.discount}
|
||||
onChange={(e) => handleInputChange('discount', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label htmlFor="manageStock">Manage Stock</Label>
|
||||
<Switch
|
||||
id="manageStock"
|
||||
checked={formData.manageStock}
|
||||
onCheckedChange={(checked) => handleInputChange('manageStock', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Enable this to track inventory levels for this product
|
||||
</p>
|
||||
|
||||
{formData.manageStock && (
|
||||
<div>
|
||||
<Label htmlFor="stock">Stock Quantity</Label>
|
||||
<Input
|
||||
id="stock"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.stock}
|
||||
onChange={(e) => handleInputChange('stock', parseInt(e.target.value) || 0)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="isActive">Active Status</Label>
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => handleInputChange('isActive', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{formData.isActive ? 'Product is visible to customers' : 'Product is hidden from customers'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
66
components/dashboard/DashboardHeader.tsx
Normal file
66
components/dashboard/DashboardHeader.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: ReactNode
|
||||
backHref?: string
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
export function DashboardHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
backHref = "/dashboard",
|
||||
actions
|
||||
}: DashboardHeaderProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-lg border border-gray-200 p-8"
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
asChild
|
||||
className="w-10 h-10 rounded-full bg-gray-100 p-0 hover:bg-gray-200"
|
||||
>
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="text-gray-600 text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actions && (
|
||||
<div className="flex items-center space-x-3">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DashboardHeader as default }
|
||||
477
components/dashboard/DashboardSidebar.tsx
Normal file
477
components/dashboard/DashboardSidebar.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Network,
|
||||
Wallet,
|
||||
ShoppingBag,
|
||||
BarChart3,
|
||||
CreditCard,
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Trophy,
|
||||
Gift,
|
||||
TrendingUp,
|
||||
Package
|
||||
} from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useSession, signOut } from 'next-auth/react'
|
||||
|
||||
interface SidebarItem {
|
||||
title: string
|
||||
href: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
badge?: string
|
||||
children?: SidebarItem[]
|
||||
roles?: string[] // Allowed roles for this item
|
||||
}
|
||||
|
||||
// Role-based navigation items
|
||||
const getSidebarItems = (userRole: string): SidebarItem[] => {
|
||||
const allItems: SidebarItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
roles: ['CUSTOMER', 'MEMBER', 'WHOLESALER', 'PART_TIME', 'ADMIN']
|
||||
},
|
||||
{
|
||||
title: 'My Orders',
|
||||
href: '/dashboard/orders',
|
||||
icon: ShoppingBag,
|
||||
roles: ['CUSTOMER', 'MEMBER', 'WHOLESALER', 'PART_TIME', 'ADMIN']
|
||||
},
|
||||
{
|
||||
title: 'Network',
|
||||
href: '',
|
||||
icon: Network,
|
||||
roles: ['MEMBER', 'WHOLESALER'],
|
||||
children: [
|
||||
{
|
||||
title: 'Team Genealogy',
|
||||
href: '/dashboard/genealogy',
|
||||
icon: Users,
|
||||
roles: ['MEMBER', 'WHOLESALER']
|
||||
},
|
||||
{
|
||||
title: 'My Referrals',
|
||||
href: '/dashboard/referrals',
|
||||
icon: Gift,
|
||||
roles: ['MEMBER', 'WHOLESALER']
|
||||
},
|
||||
{
|
||||
title: 'Team Overview',
|
||||
href: '/dashboard/team',
|
||||
icon: TrendingUp,
|
||||
roles: ['MEMBER', 'WHOLESALER']
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Achievements',
|
||||
href: '/dashboard/achievements',
|
||||
icon: Trophy,
|
||||
roles: ['MEMBER', 'WHOLESALER']
|
||||
},
|
||||
{
|
||||
title: 'Wallet & Earnings',
|
||||
href: '/dashboard/wallet',
|
||||
icon: Wallet,
|
||||
roles: ['MEMBER', 'WHOLESALER', 'PART_TIME'],
|
||||
children: [
|
||||
{
|
||||
title: 'My Wallet',
|
||||
href: '/dashboard/wallet',
|
||||
icon: Wallet,
|
||||
roles: ['MEMBER', 'WHOLESALER', 'PART_TIME']
|
||||
},
|
||||
{
|
||||
title: 'Commission History',
|
||||
href: '/dashboard/commissions',
|
||||
icon: TrendingUp,
|
||||
roles: ['MEMBER', 'WHOLESALER']
|
||||
},
|
||||
{
|
||||
title: 'Earnings',
|
||||
href: '/dashboard/earnings',
|
||||
icon: TrendingUp,
|
||||
roles: ['PART_TIME']
|
||||
},
|
||||
{
|
||||
title: 'Payout Requests',
|
||||
href: '/dashboard/payouts',
|
||||
icon: CreditCard,
|
||||
roles: ['MEMBER', 'WHOLESALER', 'PART_TIME']
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Wholesale Portal',
|
||||
href: '/dashboard/wholesale',
|
||||
icon: Package,
|
||||
roles: ['WHOLESALER']
|
||||
},
|
||||
{
|
||||
title: 'Part-Time Jobs',
|
||||
href: '/dashboard/part-time',
|
||||
icon: Users,
|
||||
roles: ['PART_TIME']
|
||||
},
|
||||
{
|
||||
title: 'Reports & Analytics',
|
||||
href: '/dashboard/reports',
|
||||
icon: BarChart3,
|
||||
roles: ['MEMBER', 'ADMIN']
|
||||
},
|
||||
{
|
||||
title: 'Profile Settings',
|
||||
href: '/dashboard/profile',
|
||||
icon: User,
|
||||
roles: ['CUSTOMER', 'MEMBER', 'WHOLESALER', 'PART_TIME', 'ADMIN']
|
||||
},
|
||||
]
|
||||
|
||||
// Filter items based on user role
|
||||
const filterItemsByRole = (items: SidebarItem[]): SidebarItem[] => {
|
||||
return items
|
||||
.filter(item => !item.roles || item.roles.includes(userRole))
|
||||
.map(item => ({
|
||||
...item,
|
||||
children: item.children ? filterItemsByRole(item.children) : undefined
|
||||
}))
|
||||
.filter(item => !item.children || item.children.length > 0) // Remove parent items with no visible children
|
||||
}
|
||||
|
||||
return filterItemsByRole(allItems)
|
||||
}
|
||||
|
||||
// Role display information
|
||||
const getRoleInfo = (role: string) => {
|
||||
const roleMap: Record<string, { label: string; description: string; className: string }> = {
|
||||
ADMIN: {
|
||||
label: 'Admin',
|
||||
description: 'Full system access',
|
||||
className: 'bg-purple-100 text-purple-800 border-purple-200'
|
||||
},
|
||||
MEMBER: {
|
||||
label: 'Partner',
|
||||
description: 'Marketing partner',
|
||||
className: 'bg-blue-100 text-blue-800 border-blue-200'
|
||||
},
|
||||
WHOLESALER: {
|
||||
label: 'Wholesaler',
|
||||
description: 'Bulk distribution partner',
|
||||
className: 'bg-yellow-100 text-yellow-800 border-yellow-200'
|
||||
},
|
||||
PART_TIME: {
|
||||
label: 'Part-time',
|
||||
description: 'Delivery & support team',
|
||||
className: 'bg-green-100 text-green-800 border-green-200'
|
||||
},
|
||||
CUSTOMER: {
|
||||
label: 'Customer',
|
||||
description: 'Valued customer',
|
||||
className: 'bg-gray-100 text-gray-800 border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
return roleMap[role] || roleMap.CUSTOMER
|
||||
}
|
||||
|
||||
interface DashboardSidebarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DashboardSidebar({ className }: DashboardSidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>([])
|
||||
const pathname = usePathname()
|
||||
const { data: session } = useSession()
|
||||
|
||||
// Get user role from session, default to CUSTOMER
|
||||
const userRole = session?.user?.role || 'CUSTOMER'
|
||||
const roleInfo = getRoleInfo(userRole)
|
||||
|
||||
// Get sidebar items based on user role
|
||||
const sidebarItems = getSidebarItems(userRole)
|
||||
|
||||
const toggleExpanded = (title: string) => {
|
||||
setExpandedItems(prev =>
|
||||
prev.includes(title)
|
||||
? prev.filter(item => item !== title)
|
||||
: [...prev, title]
|
||||
)
|
||||
}
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/dashboard') {
|
||||
return pathname === '/dashboard'
|
||||
}
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
const hasActiveChild = (children?: SidebarItem[]) => {
|
||||
if (!children) return false
|
||||
return children.some(child => isActive(child.href))
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ x: -100, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
className={cn(
|
||||
"bg-white/80 backdrop-blur-sm border-r border-gray-200 h-screen sticky top-0 overflow-y-auto transition-all duration-300",
|
||||
isCollapsed ? "w-16" : "w-64",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<AnimatePresence>
|
||||
{!isCollapsed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="flex items-center space-x-3"
|
||||
>
|
||||
<div className='w-8 h-8 rounded-lg flex items-center justify-center'>
|
||||
<Image src="/logo.png" alt="Padmaaja Logo" width={32} height={32} className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<span className="font-bold text-gray-900">Padmaaja Rasooi</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* User Profile */}
|
||||
<AnimatePresence>
|
||||
{!isCollapsed && session && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-4 mb-6"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-r from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
|
||||
{session.user.image ? (
|
||||
<Image src={session.user.image} alt="User Avatar" width={40} height={40} className="rounded-full" />
|
||||
) : (
|
||||
<User className="h-5 w-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{session.user.name || 'User'}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", roleInfo.className)}
|
||||
>
|
||||
{roleInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{roleInfo.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="space-y-2">
|
||||
{sidebarItems.map((item) => (
|
||||
<div key={item.title}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors cursor-pointer",
|
||||
isActive(item.href) || hasActiveChild(item.children)
|
||||
? "bg-blue-100 text-blue-900"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
isCollapsed && "justify-center px-2"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (item.children) {
|
||||
toggleExpanded(item.title)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Link href={item.href} className="flex items-center space-x-3 flex-1">
|
||||
<item.icon className="h-5 w-5" />
|
||||
<AnimatePresence>
|
||||
{!isCollapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
className="truncate"
|
||||
>
|
||||
{item.title}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Link>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isCollapsed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{item.badge && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
{item.children && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
expandedItems.includes(item.title) && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Submenu */}
|
||||
<AnimatePresence>
|
||||
{item.children && expandedItems.includes(item.title) && !isCollapsed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="ml-4 mt-2 space-y-1"
|
||||
>
|
||||
{item.children.map((child) => (
|
||||
<Link
|
||||
key={child.title}
|
||||
href={child.href}
|
||||
className={cn(
|
||||
"flex items-center space-x-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
isActive(child.href)
|
||||
? "bg-blue-50 text-blue-700 border-l-2 border-blue-500"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<child.icon className="h-4 w-4" />
|
||||
<span className="truncate">{child.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 space-y-2">
|
||||
<Link
|
||||
href="/"
|
||||
target="_blank"
|
||||
className={cn(
|
||||
"flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors text-gray-700 hover:bg-gray-100",
|
||||
isCollapsed && "justify-center px-2"
|
||||
)}
|
||||
>
|
||||
<Package className="h-5 w-5" />
|
||||
<AnimatePresence>
|
||||
{!isCollapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
>
|
||||
View Site
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Link>
|
||||
|
||||
{session?.user?.role === 'ADMIN' && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
pathname.startsWith('/admin')
|
||||
? "bg-purple-100 text-purple-900"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
isCollapsed && "justify-center px-2"
|
||||
)}
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
<AnimatePresence>
|
||||
{!isCollapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
>
|
||||
Admin Panel
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => signOut()}
|
||||
className={cn(
|
||||
"w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50",
|
||||
isCollapsed && "justify-center px-2"
|
||||
)}
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<AnimatePresence>
|
||||
{!isCollapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
className="ml-3"
|
||||
>
|
||||
Sign Out
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
209
components/forms/ContactForm.tsx
Normal file
209
components/forms/ContactForm.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Send } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface ContactFormProps {
|
||||
className?: string
|
||||
variant?: 'default' | 'minimal' | 'professional'
|
||||
onSubmitSuccess?: () => void
|
||||
showInquiryType?: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export default function ContactForm({}: ContactFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
inquiry_type: ''
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSelectChange = (value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
inquiry_type: value
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/forms', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
formId: 'contact', // Add formId for the simplified schema
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
subject: formData.subject,
|
||||
message: formData.message,
|
||||
inquiryType: formData.inquiry_type,
|
||||
formSource: 'contact-page'
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Message sent successfully! We\'ll get back to you soon.')
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
inquiry_type: ''
|
||||
})
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to send message')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error)
|
||||
toast.error('Failed to send message. Please try again.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name & Email Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Your full name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
className="h-11 rounded-lg border-gray-200 focus:border-orange-400 focus:ring-2 focus:ring-orange-100 transition-all duration-200"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="your@company.com"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="h-11 rounded-lg border-gray-200 focus:border-orange-400 focus:ring-2 focus:ring-orange-100 transition-all duration-200"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inquiry Type */}
|
||||
<div>
|
||||
<Label htmlFor="inquiry_type" className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
Inquiry Type
|
||||
</Label>
|
||||
<Select value={formData.inquiry_type} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger className="h-11 rounded-lg border-gray-200 focus:border-orange-400 focus:ring-2 focus:ring-orange-100">
|
||||
<SelectValue placeholder="Select inquiry type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="wholesale">Wholesale Partnership</SelectItem>
|
||||
<SelectItem value="bulk">Bulk Orders (1000+ kg)</SelectItem>
|
||||
<SelectItem value="pricing">Pricing & Catalog</SelectItem>
|
||||
<SelectItem value="partnership">Supply Chain Partnership</SelectItem>
|
||||
<SelectItem value="private-label">Private Label</SelectItem>
|
||||
<SelectItem value="export">Export & Trade</SelectItem>
|
||||
<SelectItem value="franchise">Franchise</SelectItem>
|
||||
<SelectItem value="quality">Quality Certificates</SelectItem>
|
||||
<SelectItem value="custom-packaging">Custom Packaging</SelectItem>
|
||||
<SelectItem value="general">General Inquiry</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<Label htmlFor="subject" className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
Subject
|
||||
</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
name="subject"
|
||||
type="text"
|
||||
placeholder="Brief subject"
|
||||
value={formData.subject}
|
||||
onChange={handleInputChange}
|
||||
className="h-11 rounded-lg border-gray-200 focus:border-orange-400 focus:ring-2 focus:ring-orange-100 transition-all duration-200"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<Label htmlFor="message" className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
Message
|
||||
</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
placeholder="Tell us about your requirements, quantities, and timeline..."
|
||||
rows={5}
|
||||
value={formData.message}
|
||||
onChange={handleInputChange}
|
||||
className="rounded-lg border-gray-200 focus:border-orange-400 focus:ring-2 focus:ring-orange-100 resize-none transition-all duration-200"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-orange-600 hover:bg-orange-700 text-white font-medium rounded-lg transition-colors duration-200"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
|
||||
<span>Sending...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Send className="h-4 w-4" />
|
||||
<span>Send Message</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-gray-500 text-center mt-4">
|
||||
We'll respond within 24 hours
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
517
components/forms/PartTimeRegistrationForm.tsx
Normal file
517
components/forms/PartTimeRegistrationForm.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Clock,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Briefcase,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Award,
|
||||
GraduationCap
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface PartTimeRegistrationFormProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface PartTimeFormData {
|
||||
// Personal Information
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
age: string
|
||||
gender: string
|
||||
|
||||
// Address Information
|
||||
address: string
|
||||
city: string
|
||||
state: string
|
||||
zipCode: string
|
||||
|
||||
// Professional Information
|
||||
education: string
|
||||
experience: string
|
||||
preferredRole: string
|
||||
availableHours: string
|
||||
availableDays: string
|
||||
|
||||
// Additional Information
|
||||
skills: string
|
||||
motivation: string
|
||||
previousWorkExperience: string
|
||||
languagesKnown: string
|
||||
}
|
||||
|
||||
export default function PartTimeRegistrationForm({ isOpen, onClose }: PartTimeRegistrationFormProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false)
|
||||
const [loginCredentials, setLoginCredentials] = useState<{
|
||||
email: string
|
||||
password: string
|
||||
} | null>(null)
|
||||
|
||||
const [formData, setFormData] = useState<PartTimeFormData>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
age: '',
|
||||
gender: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
education: '',
|
||||
experience: '',
|
||||
preferredRole: '',
|
||||
availableHours: '',
|
||||
availableDays: '',
|
||||
skills: '',
|
||||
motivation: '',
|
||||
previousWorkExperience: '',
|
||||
languagesKnown: ''
|
||||
})
|
||||
|
||||
const handleInputChange = (field: keyof PartTimeFormData, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/part-time/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Registration failed')
|
||||
}
|
||||
|
||||
setLoginCredentials(data.loginCredentials)
|
||||
setShowSuccessModal(true)
|
||||
onClose() // Close the main registration modal
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
age: '',
|
||||
gender: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
education: '',
|
||||
experience: '',
|
||||
preferredRole: '',
|
||||
availableHours: '',
|
||||
availableDays: '',
|
||||
skills: '',
|
||||
motivation: '',
|
||||
previousWorkExperience: '',
|
||||
languagesKnown: ''
|
||||
})
|
||||
|
||||
toast.success('Sales Executive application submitted successfully!')
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
toast.error(error instanceof Error ? error.message : 'Application submission failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl flex items-center">
|
||||
<Briefcase className="h-6 w-6 mr-3" />
|
||||
Sales Executive Application
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Apply for our Sales Executive position with guaranteed ₹10,000 salary + 1% commission
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mb-6">
|
||||
<Badge className="bg-green-100 text-green-800 px-4 py-2">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
₹10,000 Guaranteed
|
||||
</Badge>
|
||||
<Badge className="bg-blue-100 text-blue-800 px-4 py-2">
|
||||
<Award className="h-4 w-4 mr-2" />
|
||||
1% Sales Commission
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Personal Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<User className="h-5 w-5 mr-2 text-green-600" />
|
||||
Personal Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="firstName">First Name *</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="lastName">Last Name *</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Email Address *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="phone">Phone Number *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="age">Age *</Label>
|
||||
<Input
|
||||
id="age"
|
||||
type="number"
|
||||
value={formData.age}
|
||||
onChange={(e) => handleInputChange('age', e.target.value)}
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="gender">Gender *</Label>
|
||||
<Select value={formData.gender} onValueChange={(value) => handleInputChange('gender', value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select gender" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">Male</SelectItem>
|
||||
<SelectItem value="female">Female</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<MapPin className="h-5 w-5 mr-2 text-green-600" />
|
||||
Address Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="address">Full Address *</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleInputChange('address', e.target.value)}
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="city">City *</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleInputChange('city', e.target.value)}
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="state">State *</Label>
|
||||
<Input
|
||||
id="state"
|
||||
value={formData.state}
|
||||
onChange={(e) => handleInputChange('state', e.target.value)}
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="zipCode">ZIP Code *</Label>
|
||||
<Input
|
||||
id="zipCode"
|
||||
value={formData.zipCode}
|
||||
onChange={(e) => handleInputChange('zipCode', e.target.value)}
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Professional Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<GraduationCap className="h-5 w-5 mr-2 text-green-600" />
|
||||
Sales Experience & Availability
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="education">Education Level *</Label>
|
||||
<Select value={formData.education} onValueChange={(value) => handleInputChange('education', value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select education level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="high-school">High School</SelectItem>
|
||||
<SelectItem value="diploma">Diploma</SelectItem>
|
||||
<SelectItem value="bachelor">Bachelor's Degree</SelectItem>
|
||||
<SelectItem value="master">Master's Degree</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="experience">Work Experience *</Label>
|
||||
<Select value={formData.experience} onValueChange={(value) => handleInputChange('experience', value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select experience level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fresher">Fresher (0 years)</SelectItem>
|
||||
<SelectItem value="1-2">1-2 years</SelectItem>
|
||||
<SelectItem value="3-5">3-5 years</SelectItem>
|
||||
<SelectItem value="5+">5+ years</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="preferredRole">Position *</Label>
|
||||
<Select value={formData.preferredRole} onValueChange={(value) => handleInputChange('preferredRole', value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select position" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sales-executive">Sales Executive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="availableHours">Available Hours per Day *</Label>
|
||||
<Select value={formData.availableHours} onValueChange={(value) => handleInputChange('availableHours', value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select available hours" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2-3">2-3 hours</SelectItem>
|
||||
<SelectItem value="4-5">4-5 hours</SelectItem>
|
||||
<SelectItem value="6-7">6-7 hours</SelectItem>
|
||||
<SelectItem value="8+">8+ hours</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="availableDays">Available Days *</Label>
|
||||
<Select value={formData.availableDays} onValueChange={(value) => handleInputChange('availableDays', value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select available days" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="weekdays">Weekdays (Mon-Fri)</SelectItem>
|
||||
<SelectItem value="weekends">Weekends (Sat-Sun)</SelectItem>
|
||||
<SelectItem value="all-days">All Days</SelectItem>
|
||||
<SelectItem value="flexible">Flexible</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2 text-green-600" />
|
||||
Additional Information
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="skills">Skills & Expertise</Label>
|
||||
<Textarea
|
||||
id="skills"
|
||||
value={formData.skills}
|
||||
onChange={(e) => handleInputChange('skills', e.target.value)}
|
||||
placeholder="List your relevant skills, software knowledge, etc."
|
||||
className="mt-1"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="languagesKnown">Languages Known</Label>
|
||||
<Input
|
||||
id="languagesKnown"
|
||||
value={formData.languagesKnown}
|
||||
onChange={(e) => handleInputChange('languagesKnown', e.target.value)}
|
||||
placeholder="e.g., Hindi, English, Regional languages"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="previousWorkExperience">Previous Work Experience</Label>
|
||||
<Textarea
|
||||
id="previousWorkExperience"
|
||||
value={formData.previousWorkExperience}
|
||||
onChange={(e) => handleInputChange('previousWorkExperience', e.target.value)}
|
||||
placeholder="Describe your previous work experience (if any)"
|
||||
className="mt-1"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="motivation">Why do you want to join our sales team? *</Label>
|
||||
<Textarea
|
||||
id="motivation"
|
||||
value={formData.motivation}
|
||||
onChange={(e) => handleInputChange('motivation', e.target.value)}
|
||||
placeholder="Tell us about your interest in sales and what motivates you"
|
||||
className="mt-1"
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white font-semibold py-3 rounded-lg transition-all duration-200"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Submitting Application...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Briefcase className="h-5 w-5 mr-2" />
|
||||
Submit Application
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Success Modal */}
|
||||
<Dialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center text-green-600">
|
||||
<CheckCircle className="h-6 w-6 mr-2" />
|
||||
Application Submitted!
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<p>Your Sales Executive application has been submitted successfully!</p>
|
||||
|
||||
{loginCredentials && (
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Your Login Credentials:</h4>
|
||||
<p><strong>Email:</strong> {loginCredentials.email}</p>
|
||||
<p><strong>Password:</strong> {loginCredentials.password}</p>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Please save these credentials. You'll receive a welcome email shortly.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">What's Next?</h4>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li>• Our sales team will review your application</li>
|
||||
<li>• You'll receive a call within 2-3 business days</li>
|
||||
<li>• Complete the interview process</li>
|
||||
<li>• Start earning ₹10,000 + 1% commission!</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setShowSuccessModal(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
438
components/forms/PartnershipApplicationForm.tsx
Normal file
438
components/forms/PartnershipApplicationForm.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2, CheckCircle, Crown, Award, Star } from 'lucide-react'
|
||||
import { signIn } from 'next-auth/react'
|
||||
|
||||
interface PartnershipApplicationFormProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
selectedTier?: string
|
||||
}
|
||||
|
||||
export default function PartnershipApplicationForm({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedTier = 'Silver'
|
||||
}: PartnershipApplicationFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
businessName: '',
|
||||
businessType: '',
|
||||
experience: '',
|
||||
partnershipTier: selectedTier,
|
||||
expectedCustomers: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
motivation: '',
|
||||
marketingPlan: ''
|
||||
})
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
|
||||
// Update form data when selectedTier prop changes
|
||||
useEffect(() => {
|
||||
setFormData(prev => ({ ...prev, partnershipTier: selectedTier }))
|
||||
}, [selectedTier])
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFormData({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
businessName: '',
|
||||
businessType: '',
|
||||
experience: '',
|
||||
partnershipTier: selectedTier,
|
||||
expectedCustomers: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
motivation: '',
|
||||
marketingPlan: ''
|
||||
})
|
||||
setIsSuccess(false)
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [isOpen, selectedTier])
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/partnership/apply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
expectedCustomers: parseInt(formData.expectedCustomers) || 0
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setIsSuccess(true)
|
||||
toast.success('Application submitted successfully! Check your email for login details.')
|
||||
|
||||
// Auto-login the user after successful registration
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await signIn('credentials', {
|
||||
email: formData.email,
|
||||
redirect: false
|
||||
})
|
||||
window.location.href = '/dashboard'
|
||||
} catch (error) {
|
||||
console.error('Auto-login failed:', error)
|
||||
}
|
||||
}, 2000)
|
||||
} else {
|
||||
toast.error(result.message || 'Application failed. Please try again.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Application error:', error)
|
||||
toast.error('Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getTierIcon = (tier: string) => {
|
||||
switch (tier) {
|
||||
case 'Diamond': return Crown
|
||||
case 'Gold': return Award
|
||||
case 'Silver': return Star
|
||||
default: return Star
|
||||
}
|
||||
}
|
||||
|
||||
const getTierColor = (tier: string) => {
|
||||
switch (tier) {
|
||||
case 'Diamond': return '#3B82F6'
|
||||
case 'Gold': return '#F59E0B'
|
||||
case 'Silver': return '#8B5CF6'
|
||||
default: return '#8B5CF6'
|
||||
}
|
||||
}
|
||||
|
||||
const getTierMaxCustomers = (tier: string) => {
|
||||
switch (tier) {
|
||||
case 'Diamond': return 3000
|
||||
case 'Gold': return 1500
|
||||
case 'Silver': return 500
|
||||
default: return 500
|
||||
}
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="h-16 w-16 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Application Submitted!
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Welcome to Padmaaja Rasooi! Check your email for login details.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
You'll be automatically logged in and redirected to your dashboard.
|
||||
</p>
|
||||
<Button onClick={onClose} className="w-full">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold text-center">
|
||||
Partnership Application
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Selected Tier Display */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
{(() => {
|
||||
const TierIcon = getTierIcon(formData.partnershipTier)
|
||||
return (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
style={{backgroundColor: `${getTierColor(formData.partnershipTier)}20`}}
|
||||
>
|
||||
<TierIcon
|
||||
className="h-6 w-6"
|
||||
style={{color: getTierColor(formData.partnershipTier)}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{formData.partnershipTier} Partner</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Max {getTierMaxCustomers(formData.partnershipTier).toLocaleString()} customers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Personal Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Personal Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="firstName">First Name *</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="lastName">Last Name *</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Email Address *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="phone">Phone Number *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Business Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="businessName">Business Name (Optional)</Label>
|
||||
<Input
|
||||
id="businessName"
|
||||
value={formData.businessName}
|
||||
onChange={(e) => handleInputChange('businessName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="businessType">Business Type *</Label>
|
||||
<Select value={formData.businessType} onValueChange={(value) => handleInputChange('businessType', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select business type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="individual">Individual</SelectItem>
|
||||
<SelectItem value="retail">Retail Store</SelectItem>
|
||||
<SelectItem value="restaurant">Restaurant/Hotel</SelectItem>
|
||||
<SelectItem value="distributor">Distributor</SelectItem>
|
||||
<SelectItem value="online">Online Business</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="experience">Experience in Food/Retail Business *</Label>
|
||||
<Select value={formData.experience} onValueChange={(value) => handleInputChange('experience', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select your experience level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beginner">Beginner (0-1 years)</SelectItem>
|
||||
<SelectItem value="intermediate">Intermediate (2-5 years)</SelectItem>
|
||||
<SelectItem value="experienced">Experienced (5+ years)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Partnership Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Partnership Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="partnershipTier">Partnership Tier *</Label>
|
||||
<Select value={formData.partnershipTier} onValueChange={(value) => handleInputChange('partnershipTier', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Silver">Silver (500 customers)</SelectItem>
|
||||
<SelectItem value="Gold">Gold (1,500 customers)</SelectItem>
|
||||
<SelectItem value="Diamond">Diamond (3,000 customers)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="expectedCustomers">Expected Customers in First Year</Label>
|
||||
<Input
|
||||
id="expectedCustomers"
|
||||
type="number"
|
||||
value={formData.expectedCustomers}
|
||||
onChange={(e) => handleInputChange('expectedCustomers', e.target.value)}
|
||||
max={getTierMaxCustomers(formData.partnershipTier)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Address Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Address Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="address">Street Address *</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleInputChange('address', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="city">City *</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleInputChange('city', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="state">State *</Label>
|
||||
<Input
|
||||
id="state"
|
||||
value={formData.state}
|
||||
onChange={(e) => handleInputChange('state', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="zipCode">ZIP Code *</Label>
|
||||
<Input
|
||||
id="zipCode"
|
||||
value={formData.zipCode}
|
||||
onChange={(e) => handleInputChange('zipCode', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Additional Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Additional Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="motivation">Why do you want to become a partner? *</Label>
|
||||
<Textarea
|
||||
id="motivation"
|
||||
value={formData.motivation}
|
||||
onChange={(e) => handleInputChange('motivation', e.target.value)}
|
||||
required
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="marketingPlan">How do you plan to market our products? *</Label>
|
||||
<Textarea
|
||||
id="marketingPlan"
|
||||
value={formData.marketingPlan}
|
||||
onChange={(e) => handleInputChange('marketingPlan', e.target.value)}
|
||||
required
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit Application'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
465
components/forms/WholesalerRegistrationForm.tsx
Normal file
465
components/forms/WholesalerRegistrationForm.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Store,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Building,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Gift
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface WholesalerRegistrationFormProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface WholesalerFormData {
|
||||
// Business Information
|
||||
businessName: string
|
||||
businessType: string
|
||||
gstNumber: string
|
||||
panNumber: string
|
||||
|
||||
// Personal Information
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
|
||||
// Address Information
|
||||
address: string
|
||||
city: string
|
||||
state: string
|
||||
zipCode: string
|
||||
|
||||
// Business Details
|
||||
experience: string
|
||||
expectedOrderVolume: string
|
||||
productCategories: string
|
||||
businessDescription: string
|
||||
}
|
||||
|
||||
export default function WholesalerRegistrationForm({ isOpen, onClose }: WholesalerRegistrationFormProps) {
|
||||
const [formData, setFormData] = useState<WholesalerFormData>({
|
||||
businessName: '',
|
||||
businessType: '',
|
||||
gstNumber: '',
|
||||
panNumber: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
experience: '',
|
||||
expectedOrderVolume: '',
|
||||
productCategories: '',
|
||||
businessDescription: ''
|
||||
})
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false)
|
||||
const [loginCredentials, setLoginCredentials] = useState<{
|
||||
email: string
|
||||
password: string
|
||||
} | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wholesaler/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Registration failed')
|
||||
}
|
||||
|
||||
setLoginCredentials(data.loginCredentials)
|
||||
setShowSuccessModal(true)
|
||||
onClose() // Close the main registration modal
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
businessName: '',
|
||||
businessType: '',
|
||||
gstNumber: '',
|
||||
panNumber: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
experience: '',
|
||||
expectedOrderVolume: '',
|
||||
productCategories: '',
|
||||
businessDescription: ''
|
||||
})
|
||||
|
||||
toast.success('Wholesaler registration successful!')
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
toast.error(error instanceof Error ? error.message : 'Registration failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof WholesalerFormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl flex items-center">
|
||||
<Building className="h-6 w-6 mr-3" />
|
||||
Wholesaler Registration
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Join our network and get exclusive access to premium products with 25% wholesale discount on bulk orders
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mb-6">
|
||||
<Badge className="bg-green-100 text-green-800 px-4 py-2">
|
||||
<Gift className="h-4 w-4 mr-2" />
|
||||
25% Discount
|
||||
</Badge>
|
||||
<Badge className="bg-blue-100 text-blue-800 px-4 py-2">
|
||||
<Store className="h-4 w-4 mr-2" />
|
||||
Bulk Orders
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Business Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<Building className="h-5 w-5 mr-2 text-blue-600" />
|
||||
Business Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label htmlFor="businessName">Business Name *</Label>
|
||||
<Input
|
||||
id="businessName"
|
||||
value={formData.businessName}
|
||||
onChange={(e) => handleInputChange('businessName', e.target.value)}
|
||||
placeholder="Enter your business name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="businessType">Business Type *</Label>
|
||||
<Select onValueChange={(value) => handleInputChange('businessType', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select business type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="retail">Retail Store</SelectItem>
|
||||
<SelectItem value="distributor">Distributor</SelectItem>
|
||||
<SelectItem value="restaurant">Restaurant/Hotel</SelectItem>
|
||||
<SelectItem value="supermarket">Supermarket</SelectItem>
|
||||
<SelectItem value="export">Export Business</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="gstNumber">GST Number</Label>
|
||||
<Input
|
||||
id="gstNumber"
|
||||
value={formData.gstNumber}
|
||||
onChange={(e) => handleInputChange('gstNumber', e.target.value)}
|
||||
placeholder="Enter GST number"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="panNumber">PAN Number</Label>
|
||||
<Input
|
||||
id="panNumber"
|
||||
value={formData.panNumber}
|
||||
onChange={(e) => handleInputChange('panNumber', e.target.value)}
|
||||
placeholder="Enter PAN number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Personal Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<User className="h-5 w-5 mr-2 text-blue-600" />
|
||||
Personal Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label htmlFor="firstName">First Name *</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||
placeholder="Enter your first name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="lastName">Last Name *</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
||||
placeholder="Enter your last name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Email Address *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
placeholder="Enter your email address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="phone">Phone Number *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
placeholder="Enter your phone number"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<MapPin className="h-5 w-5 mr-2 text-blue-600" />
|
||||
Address Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<Label htmlFor="address">Business Address *</Label>
|
||||
<Textarea
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleInputChange('address', e.target.value)}
|
||||
placeholder="Enter your complete business address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<Label htmlFor="city">City *</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleInputChange('city', e.target.value)}
|
||||
placeholder="Enter city"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="state">State *</Label>
|
||||
<Input
|
||||
id="state"
|
||||
value={formData.state}
|
||||
onChange={(e) => handleInputChange('state', e.target.value)}
|
||||
placeholder="Enter state"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="zipCode">ZIP Code *</Label>
|
||||
<Input
|
||||
id="zipCode"
|
||||
value={formData.zipCode}
|
||||
onChange={(e) => handleInputChange('zipCode', e.target.value)}
|
||||
placeholder="Enter ZIP code"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business Details */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2 text-blue-600" />
|
||||
Business Details
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label htmlFor="experience">Business Experience</Label>
|
||||
<Select onValueChange={(value) => handleInputChange('experience', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select experience" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="startup">Startup (0-1 years)</SelectItem>
|
||||
<SelectItem value="growing">Growing (1-3 years)</SelectItem>
|
||||
<SelectItem value="established">Established (3-5 years)</SelectItem>
|
||||
<SelectItem value="mature">Mature (5+ years)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="expectedOrderVolume">Expected Monthly Order Volume</Label>
|
||||
<Select onValueChange={(value) => handleInputChange('expectedOrderVolume', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select volume" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">Small (₹1L - ₹5L)</SelectItem>
|
||||
<SelectItem value="medium">Medium (₹5L - ₹20L)</SelectItem>
|
||||
<SelectItem value="large">Large (₹20L - ₹50L)</SelectItem>
|
||||
<SelectItem value="enterprise">Enterprise (₹50L+)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Label htmlFor="productCategories">Product Categories of Interest</Label>
|
||||
<Input
|
||||
id="productCategories"
|
||||
value={formData.productCategories}
|
||||
onChange={(e) => handleInputChange('productCategories', e.target.value)}
|
||||
placeholder="e.g., Basmati Rice, Non-Basmati Rice, Organic Rice"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Label htmlFor="businessDescription">Business Description</Label>
|
||||
<Textarea
|
||||
id="businessDescription"
|
||||
value={formData.businessDescription}
|
||||
onChange={(e) => handleInputChange('businessDescription', e.target.value)}
|
||||
placeholder="Tell us about your business, target market, and goals"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-6 rounded-lg">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Wholesaler Benefits:</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• 25% discount on all bulk orders</li>
|
||||
<li>• Dedicated account manager</li>
|
||||
<li>• Priority customer support</li>
|
||||
<li>• Flexible payment terms</li>
|
||||
<li>• Quality guarantee on all products</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 text-lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Registering...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Store className="h-5 w-5 mr-2" />
|
||||
Register as Wholesaler
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Success Modal */}
|
||||
<Dialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center text-green-600">
|
||||
<CheckCircle className="h-6 w-6 mr-2" />
|
||||
Registration Successful!
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<p>Your wholesaler account has been created successfully!</p>
|
||||
|
||||
{loginCredentials && (
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Your Login Credentials:</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Email:</span> {loginCredentials.email}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Password:</span> {loginCredentials.password}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-2">
|
||||
Please save these credentials and change your password after first login.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>• You will receive a confirmation email shortly</p>
|
||||
<p>• Our team will contact you within 24 hours</p>
|
||||
<p>• You can now access 25% wholesale discounts</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
193
components/layout/MegaMenu.tsx
Normal file
193
components/layout/MegaMenu.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Wheat, Leaf, ShoppingBag, Building2, Settings, LucideIcon } from 'lucide-react'
|
||||
|
||||
interface MegaMenuItem {
|
||||
name: string
|
||||
href: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface MegaMenuCategory {
|
||||
name: string
|
||||
id?: string
|
||||
icon: LucideIcon
|
||||
items: MegaMenuItem[]
|
||||
}
|
||||
|
||||
interface MegaMenuData {
|
||||
title: string
|
||||
type: 'categories'
|
||||
categories: MegaMenuCategory[]
|
||||
}
|
||||
|
||||
interface MegaMenuProps {
|
||||
menuKey: string
|
||||
menu: MegaMenuData
|
||||
isActive: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function MegaMenu({ menuKey, menu, isActive, onClose }: MegaMenuProps) {
|
||||
const [latestProducts, setLatestProducts] = useState<MegaMenuItem[]>([])
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(true)
|
||||
|
||||
// Fetch 4 latest products for Products menu on component mount
|
||||
useEffect(() => {
|
||||
if (menuKey === 'products') {
|
||||
const fetchLatestProducts = async () => {
|
||||
try {
|
||||
setIsLoadingProducts(true)
|
||||
|
||||
// Fetch latest 4 products directly
|
||||
const productsResponse = await fetch('/api/products?limit=4&sort=latest')
|
||||
const productsData = await productsResponse.json()
|
||||
|
||||
if (productsData && productsData.products) {
|
||||
const products = productsData.products.map((product: any) => ({
|
||||
name: product.name,
|
||||
href: `/products/${product.slug}`,
|
||||
description: product.description?.substring(0, 50) + '...' || 'Premium quality product'
|
||||
}))
|
||||
|
||||
setLatestProducts(products)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching latest products:', error)
|
||||
// Fallback to static data if API fails
|
||||
setLatestProducts([
|
||||
{ name: 'Premium Basmati Rice', href: '/products', description: 'Aromatic long-grain rice' },
|
||||
{ name: 'Kashmina Rice', href: '/products', description: 'Premium quality rice' },
|
||||
{ name: 'Non-Basmati Rice', href: '/products', description: 'Traditional varieties' },
|
||||
{ name: 'Multigrain Flour', href: '/products', description: 'Nutritious blend' },
|
||||
])
|
||||
} finally {
|
||||
setIsLoadingProducts(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchLatestProducts()
|
||||
} else {
|
||||
setIsLoadingProducts(false)
|
||||
}
|
||||
}, [menuKey]) // Only depend on menuKey, not isActive
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute top-full mt-2 bg-white rounded-xl shadow-2xl border border-slate-200/50 backdrop-blur-lg overflow-hidden z-50 right-0"
|
||||
style={{
|
||||
width: 'auto',
|
||||
minWidth: menuKey === 'products' ? '320px' : '280px',
|
||||
maxWidth: '90vw'
|
||||
}}
|
||||
>
|
||||
<div className="p-3 sm:p-4">
|
||||
{menuKey === 'products' ? (
|
||||
// Latest Products - Simple List
|
||||
isLoadingProducts ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 pb-1.5 border-b border-slate-100">
|
||||
<div className="w-4 h-4 bg-slate-200 rounded animate-pulse"></div>
|
||||
<div className="h-4 bg-slate-200 rounded w-32 animate-pulse"></div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<div key={item} className="p-2">
|
||||
<div className="h-3 bg-slate-200 rounded w-full mb-1 animate-pulse"></div>
|
||||
<div className="h-2 bg-slate-100 rounded w-3/4 animate-pulse"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 pb-1.5 border-b border-slate-100">
|
||||
<ShoppingBag className="w-4 h-4 text-emerald-600 flex-shrink-0" />
|
||||
<h3 className="font-semibold text-slate-800 text-sm">Latest Products</h3>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{latestProducts.map((product, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
href={product.href}
|
||||
className="block p-2 rounded-lg hover:bg-slate-50 transition-colors duration-200 group"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="font-medium text-slate-800 group-hover:text-emerald-600 transition-colors duration-200 text-sm truncate">
|
||||
{product.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 truncate">
|
||||
{product.description}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// Other menu types - Categories
|
||||
menu.type === 'categories' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
{menu.categories?.map((category, idx) => {
|
||||
const IconComponent = category.icon
|
||||
return (
|
||||
<div key={idx} className="space-y-2 sm:space-y-3">
|
||||
<div className="flex items-center space-x-2 pb-1.5 border-b border-slate-100">
|
||||
<IconComponent className="w-4 h-4 text-emerald-600 flex-shrink-0" />
|
||||
<h3 className="font-semibold text-slate-800 text-sm truncate">{category.name}</h3>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{category.items?.map((item, itemIdx) => (
|
||||
<Link
|
||||
key={itemIdx}
|
||||
href={item.href}
|
||||
className="block p-2 rounded-lg hover:bg-slate-50 transition-colors duration-200 group"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="font-medium text-slate-800 group-hover:text-emerald-600 transition-colors duration-200 text-sm truncate">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 truncate">
|
||||
{item.description}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* View All Products Button - Only for Products Menu */}
|
||||
{menuKey === 'products' && !isLoadingProducts && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-100">
|
||||
<Link
|
||||
href="/products"
|
||||
className="block w-full p-3 rounded-lg bg-gradient-to-r from-emerald-600 to-blue-600 text-white hover:from-emerald-700 hover:to-blue-700 transition-all duration-200 group text-center font-medium"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span>View All Products</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
191
components/layout/MobileTabBar.tsx
Normal file
191
components/layout/MobileTabBar.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
Home,
|
||||
ShoppingBag,
|
||||
ShoppingCart,
|
||||
User,
|
||||
MoreHorizontal
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import CartSidebar from '@/components/shop/CartSidebar'
|
||||
import { cartManager } from '@/lib/cart'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
icon: Home,
|
||||
href: '/',
|
||||
activeRoutes: ['/']
|
||||
},
|
||||
{
|
||||
id: 'products',
|
||||
label: 'Products',
|
||||
icon: ShoppingBag,
|
||||
href: '/products',
|
||||
activeRoutes: ['/products', '/products/[slug]']
|
||||
},
|
||||
{
|
||||
id: 'cart',
|
||||
label: 'Cart',
|
||||
icon: ShoppingCart,
|
||||
href: '/cart',
|
||||
activeRoutes: ['/cart', '/checkout']
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
label: 'Account',
|
||||
icon: User,
|
||||
href: '/dashboard',
|
||||
activeRoutes: ['/dashboard']
|
||||
},
|
||||
// {
|
||||
// id: 'more',
|
||||
// label: 'More',
|
||||
// icon: MoreHorizontal,
|
||||
// href: '/more',
|
||||
// activeRoutes: ['/more', '/about', '/contact', '/partnership', '/bulk-supply']
|
||||
// }
|
||||
]
|
||||
|
||||
export default function MobileTabBar() {
|
||||
const pathname = usePathname()
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const [lastScrollY, setLastScrollY] = useState(0)
|
||||
const [cartItemCount, setCartItemCount] = useState(0)
|
||||
|
||||
// Load cart count
|
||||
const loadCartCount = () => {
|
||||
const cart = cartManager.getCart()
|
||||
const count = cart.reduce((sum, item) => sum + item.quantity, 0)
|
||||
setCartItemCount(count)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadCartCount()
|
||||
|
||||
const handleCartUpdate = () => loadCartCount()
|
||||
window.addEventListener('cartUpdated', handleCartUpdate)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('cartUpdated', handleCartUpdate)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Hide/show tab bar on scroll
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const currentScrollY = window.scrollY
|
||||
|
||||
if (currentScrollY > lastScrollY && currentScrollY > 100) {
|
||||
setIsVisible(false) // Hide when scrolling down
|
||||
} else {
|
||||
setIsVisible(true) // Show when scrolling up
|
||||
}
|
||||
|
||||
setLastScrollY(currentScrollY)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [lastScrollY])
|
||||
|
||||
const isActiveTab = (tab: typeof tabs[0]) => {
|
||||
if (tab.activeRoutes.includes(pathname)) return true
|
||||
return tab.activeRoutes.some(route => {
|
||||
if (route.includes('[slug]')) {
|
||||
const baseRoute = route.replace('/[slug]', '')
|
||||
return pathname.startsWith(baseRoute) && pathname !== baseRoute
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 shadow-lg transition-transform duration-300 md:hidden",
|
||||
isVisible ? "translate-y-0" : "translate-y-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-around px-2 py-2 safe-area-bottom">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const isActive = isActiveTab(tab)
|
||||
|
||||
// Special handling for cart tab
|
||||
if (tab.id === 'cart') {
|
||||
return (
|
||||
<CartSidebar key={tab.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center py-2 px-3 rounded-lg transition-all duration-200 min-w-0 flex-1 relative cursor-pointer",
|
||||
"text-gray-600 hover:text-emerald-600 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<Icon
|
||||
className="h-5 w-5 mb-1 transition-transform duration-200"
|
||||
/>
|
||||
{cartItemCount > 0 && (
|
||||
<Badge
|
||||
className="absolute -top-2 -right-2 h-4 w-4 flex items-center justify-center p-0 bg-red-500 text-white !text-xs"
|
||||
>
|
||||
{cartItemCount > 99 ? '99+' : cartItemCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-medium truncate transition-colors duration-200 text-gray-600"
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
</div>
|
||||
</CartSidebar>
|
||||
)
|
||||
}
|
||||
|
||||
// Regular tabs
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center py-2 px-3 rounded-lg transition-all duration-200 min-w-0 flex-1",
|
||||
isActive
|
||||
? "text-emerald-600 bg-emerald-50"
|
||||
: "text-gray-600 hover:text-emerald-600 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-5 w-5 mb-1 transition-transform duration-200",
|
||||
isActive ? "scale-110" : ""
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium truncate transition-colors duration-200",
|
||||
isActive ? "text-emerald-700" : "text-gray-600"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<div className="absolute -top-0.5 left-1/2 transform -translate-x-1/2 w-8 h-0.5 bg-emerald-600 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Safe area padding for devices with home indicator */}
|
||||
<div className="h-safe-area-inset-bottom bg-white" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
components/layout/footer.tsx
Normal file
145
components/layout/footer.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import Link from 'next/link'
|
||||
import { Linkedin, Facebook, Instagram, Mail, Phone, MapPin, Award, Truck, Shield } from 'lucide-react'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="bg-gray-900 text-white">
|
||||
{/* Main Footer Content */}
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-8 sm:py-12 lg:py-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6 sm:gap-8">
|
||||
|
||||
{/* Company Info - Full width on mobile */}
|
||||
<div className="lg:col-span-2 mb-6 lg:mb-0">
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-orange-400 mb-2">Padmaaja</h3>
|
||||
<p className="text-gray-400 text-sm sm:text-base leading-relaxed">
|
||||
India's leading manufacturer and exporter of premium Basmati rice and multigrain flour.
|
||||
Committed to delivering authentic quality and taste that has been trusted for generations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<MapPin className="h-4 w-4 sm:h-5 sm:w-5 text-orange-400 mt-1 flex-shrink-0" />
|
||||
<div className="text-xs sm:text-sm">
|
||||
<p className="font-medium">Corporate Office</p>
|
||||
<p className="text-gray-400">11B/79, Vrindavan Colony, Lucknow - 226029</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Phone className="h-4 w-4 sm:h-5 sm:w-5 text-orange-400" />
|
||||
<div className="text-xs sm:text-sm">
|
||||
<a href="tel:+91-9475758817" className="hover:text-orange-400 transition-colors">
|
||||
+91-9475758817
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Mail className="h-4 w-4 sm:h-5 sm:w-5 text-orange-400" />
|
||||
<div className="text-xs sm:text-sm">
|
||||
<a href="mailto:info@padmajarice.com" className="hover:text-orange-400 transition-colors">
|
||||
info@padmajarice.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu sections - Responsive layout */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 lg:gap-8">
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-base sm:text-lg mb-3 sm:mb-4 text-white">Quick Links</h4>
|
||||
<ul className="space-y-1.5 sm:space-y-2 text-xs sm:text-sm">
|
||||
<li><Link href="/about" className="text-gray-400 hover:text-orange-400 transition-colors">About Us</Link></li>
|
||||
<li><Link href="/products" className="text-gray-400 hover:text-orange-400 transition-colors">Our Products</Link></li>
|
||||
<li><Link href="/partnership" className="text-gray-400 hover:text-orange-400 transition-colors">Partnership</Link></li>
|
||||
<li><Link href="/wholesaler" className="text-gray-400 hover:text-orange-400 transition-colors">Become Wholesaler</Link></li>
|
||||
<li><Link href="/contact" className="text-gray-400 hover:text-orange-400 transition-colors">Contact Us</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Business Solutions */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-base sm:text-lg mb-3 sm:mb-4 text-white">Business</h4>
|
||||
<ul className="space-y-1.5 sm:space-y-2 text-xs sm:text-sm">
|
||||
<li><Link href="/wholesaler" className="text-gray-400 hover:text-orange-400 transition-colors">Distributor Program</Link></li>
|
||||
<li><Link href="/partnership" className="text-gray-400 hover:text-orange-400 transition-colors">Business Partnership</Link></li>
|
||||
<li><Link href="/contact" className="text-gray-400 hover:text-orange-400 transition-colors">Private Label</Link></li>
|
||||
<li><Link href="/contact" className="text-gray-400 hover:text-orange-400 transition-colors">Export Inquiry</Link></li>
|
||||
<li><Link href="/contact" className="text-gray-400 hover:text-orange-400 transition-colors">Franchise</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal & Support - Responsive column spanning */}
|
||||
<div className="col-span-2 lg:col-span-1">
|
||||
<h4 className="font-semibold text-base sm:text-lg mb-3 sm:mb-4 text-white">Support</h4>
|
||||
<ul className="space-y-1.5 sm:space-y-2 text-xs sm:text-sm grid grid-cols-2 lg:grid-cols-1 gap-x-4 lg:gap-x-0">
|
||||
<li><Link href="/orders" className="text-gray-400 hover:text-orange-400 transition-colors">Track Order</Link></li>
|
||||
<li><Link href="/profile" className="text-gray-400 hover:text-orange-400 transition-colors">My Account</Link></li>
|
||||
<li><Link href="/terms-of-service" className="text-gray-400 hover:text-orange-400 transition-colors">Terms of Service</Link></li>
|
||||
<li><Link href="/privacy-policy" className="text-gray-400 hover:text-orange-400 transition-colors">Privacy Policy</Link></li>
|
||||
<li><Link href="/refund-policy" className="text-gray-400 hover:text-orange-400 transition-colors">Refund Policy</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Media & Newsletter */}
|
||||
{/* <div className="mt-6 sm:mt-8 pt-6 sm:pt-8 border-t border-gray-800">
|
||||
<div className="flex flex-col space-y-4 sm:space-y-6 lg:space-y-0 lg:flex-row lg:justify-between lg:items-center"> */}
|
||||
|
||||
{/* Social Media */}
|
||||
{/* <div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4 lg:space-x-6">
|
||||
<span className="text-sm font-medium text-gray-400">Follow Us:</span>
|
||||
<div className="flex space-x-4">
|
||||
<Link href="#" className="text-gray-400 hover:text-orange-400 transition-colors">
|
||||
<Linkedin className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
</Link>
|
||||
<Link href="#" className="text-gray-400 hover:text-orange-400 transition-colors">
|
||||
<Facebook className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
</Link>
|
||||
<Link href="#" className="text-gray-400 hover:text-orange-400 transition-colors">
|
||||
<Instagram className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
</Link>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* Newsletter Signup */}
|
||||
{/* <div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-3">
|
||||
<span className="text-sm font-medium text-gray-400">Stay Updated:</span>
|
||||
<div className="flex w-full sm:w-auto">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
className="flex-1 sm:flex-none px-3 py-2 bg-gray-800 border border-gray-700 rounded-l-md text-sm focus:outline-none focus:border-orange-400 text-white placeholder-gray-500 w-full sm:w-48"
|
||||
/>
|
||||
<button className="px-3 sm:px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white text-sm rounded-r-md transition-colors">
|
||||
Subscribe
|
||||
</button>
|
||||
</div>
|
||||
</div> */}
|
||||
{/* </div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="bg-gray-950">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center text-xs sm:text-sm text-gray-400 space-y-2 sm:space-y-0">
|
||||
<div>
|
||||
<p>© 2024 Padmaaja. All rights reserved.</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Crafted with ❤️ by <Link href='https://web.jabin.org' target='_blank'>Jabin Web</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
366
components/layout/header.tsx
Normal file
366
components/layout/header.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronDown, Menu, X, Wheat, Leaf, Package, Building2, Settings, ShoppingBag, LogOut, LucideIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useSession, signOut } from 'next-auth/react'
|
||||
import CartSidebar from '@/components/shop/CartSidebar'
|
||||
import MegaMenu from '@/components/layout/MegaMenu'
|
||||
import { isFeatureEnabled } from '@/lib/business-config'
|
||||
|
||||
interface MenuItem {
|
||||
name: string
|
||||
href: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface MenuCategory {
|
||||
name: string
|
||||
icon: LucideIcon
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
interface MegaMenuConfig {
|
||||
title: string
|
||||
type: 'categories'
|
||||
categories: MenuCategory[]
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null)
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
|
||||
const navItems = [
|
||||
// { name: 'Home', href: '/' },
|
||||
{ name: 'Recipes', href: '/recipes' },
|
||||
// { name: 'About', href: '/about' },
|
||||
{ name: 'Contact', href: '/contact' },
|
||||
]
|
||||
|
||||
const megaMenus: Record<string, MegaMenuConfig> = {
|
||||
products: {
|
||||
title: 'Products',
|
||||
type: 'categories' as const,
|
||||
categories: [] // Will be populated dynamically by MegaMenu component
|
||||
},
|
||||
company: {
|
||||
title: 'Company',
|
||||
type: 'categories' as const,
|
||||
categories: [
|
||||
{
|
||||
name: 'About Us',
|
||||
icon: Building2,
|
||||
items: [
|
||||
{ name: 'Our Story', href: '/about', description: 'Company history and mission' },
|
||||
{ name: 'Our Founder', href: '/about/founder', description: 'Meet our visionary leader' },
|
||||
{ name: 'Certifications', href: '/about/certifications', description: 'Industry recognition' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Business',
|
||||
icon: Settings,
|
||||
items: [
|
||||
{ name: 'Partnership', href: '/partnership', description: 'Join our network' },
|
||||
{ name: 'Wholesaler', href: '/wholesaler', description: 'Wholesaler program' },
|
||||
{ name: 'Sustainability', href: '/sustainability', description: 'Environmental commitment' },
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const { data: session } = useSession()
|
||||
|
||||
const adminNavigation = [
|
||||
{ name: 'Admin Dashboard', href: '/admin', icon: Settings },
|
||||
{ name: 'Manage Products', href: '/admin/products', icon: Package },
|
||||
{ name: 'Manage Orders', href: '/admin/orders', icon: ShoppingBag },
|
||||
]
|
||||
|
||||
const memberNavigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: Settings },
|
||||
{ name: 'My Orders', href: '/dashboard/orders', icon: ShoppingBag },
|
||||
{ name: 'Profile', href: '/dashboard/profile', icon: Settings },
|
||||
]
|
||||
|
||||
// Scroll detection for sticky header
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.scrollY
|
||||
const scrolled = scrollTop > 0
|
||||
setIsScrolled(scrolled)
|
||||
|
||||
// Add/remove class to body for content padding
|
||||
if (scrolled) {
|
||||
document.body.classList.add('header-sticky')
|
||||
} else {
|
||||
document.body.classList.remove('header-sticky')
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
document.body.classList.remove('header-sticky')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (mobileMenuOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [mobileMenuOpen])
|
||||
|
||||
const handleMouseEnter = (dropdown: string) => {
|
||||
setActiveDropdown(dropdown)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setActiveDropdown(null)
|
||||
}
|
||||
|
||||
const handleMobileMenuClick = () => {
|
||||
setMobileMenuOpen(!mobileMenuOpen)
|
||||
}
|
||||
|
||||
const handleMobileLinkClick = () => {
|
||||
setMobileMenuOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className={`transition-all duration-300 ${
|
||||
isScrolled
|
||||
? 'fixed top-0 left-0 right-0 z-50 bg-white/95 backdrop-blur-md border-b border-slate-200 shadow-lg'
|
||||
: 'relative bg-white border-b border-slate-200'
|
||||
}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className={`flex justify-between items-center transition-all duration-300 ${
|
||||
isScrolled ? 'h-20' : 'h-24'
|
||||
}`}>
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex-shrink-0 flex items-center z-10">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Image
|
||||
src="/kashmina-logo.png"
|
||||
alt="PADMAAJA RASOOI PVT. LTD."
|
||||
width={150}
|
||||
height={150}
|
||||
className="object-contain"
|
||||
/>
|
||||
{/* <div className="flex flex-col">
|
||||
<span className="text-lg font-bold text-slate-800 transition-colors duration-300 leading-tight">
|
||||
KASHMINA RICE
|
||||
</span>
|
||||
<span className="text-xs uppercase text-slate-600 transition-colors duration-300 leading-tight">
|
||||
"Premium Rice"
|
||||
</span>
|
||||
</div> */}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden lg:flex items-center space-x-8">
|
||||
{/* Regular Nav Items */}
|
||||
{navItems.map(item => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="text-base font-medium text-slate-700 hover:text-emerald-600 transition-colors duration-200"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Mega Menu Items */}
|
||||
{Object.entries(megaMenus).map(([key, menu]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="relative"
|
||||
onMouseEnter={() => handleMouseEnter(key)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
className={`flex items-center space-x-1 text-base font-medium text-slate-700 hover:text-emerald-600 transition-colors duration-200 ${
|
||||
activeDropdown === key ? 'text-emerald-600' : ''
|
||||
}`}
|
||||
>
|
||||
<span>{menu.title}</span>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${
|
||||
activeDropdown === key ? 'rotate-180' : ''
|
||||
}`} />
|
||||
</button>
|
||||
|
||||
{/* Mega Menu Dropdown */}
|
||||
<MegaMenu
|
||||
menuKey={key}
|
||||
menu={menu}
|
||||
isActive={activeDropdown === key}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* B2C Feature - Cart Sidebar (Disabled for B2B mode) */}
|
||||
{isFeatureEnabled('cart') && <CartSidebar />}
|
||||
|
||||
{session ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={session.user.image || ''} alt={session.user.name || ''} />
|
||||
<AvatarFallback>
|
||||
{session.user.name?.[0] || session.user.email?.[0] || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{session.user.name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{session.user.role === 'ADMIN' && adminNavigation.map((item) => (
|
||||
<DropdownMenuItem key={item.name} asChild>
|
||||
<Link href={item.href} className="flex items-center">
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
{(session.user.role === 'MEMBER' || session.user.role === 'ADMIN') &&
|
||||
memberNavigation.map((item) => (
|
||||
<DropdownMenuItem key={item.name} asChild>
|
||||
<Link href={item.href} className="flex items-center">
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => signOut()}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" className="h-10 px-4 py-2 text-base font-medium" asChild>
|
||||
<Link href="/auth/signin">Sign In</Link>
|
||||
</Button>
|
||||
<Button className="h-10 px-4 py-2 text-base font-medium" asChild>
|
||||
<Link href="/auth/signup">Sign Up</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="lg:hidden">
|
||||
<button
|
||||
onClick={handleMobileMenuClick}
|
||||
className="p-4 rounded-lg text-slate-700 hover:bg-slate-100 transition-colors duration-200 touch-manipulation min-w-[48px] min-h-[48px] flex items-center justify-center"
|
||||
aria-label="Toggle mobile menu"
|
||||
>
|
||||
{mobileMenuOpen ? <X className="w-7 h-7" /> : <Menu className="w-7 h-7" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="lg:hidden border-t bg-white border-slate-200 shadow-lg relative z-50"
|
||||
>
|
||||
<div className="px-4 py-6 space-y-6 max-h-[80vh] overflow-y-auto">
|
||||
{/* Regular Nav Items */}
|
||||
{navItems.map(item => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="mobile-nav-item block text-lg font-medium text-slate-700 hover:text-emerald-600 transition-colors duration-200 py-3 px-2 rounded-lg hover:bg-slate-50 touch-manipulation"
|
||||
onClick={handleMobileLinkClick}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Mobile Mega Menu Items */}
|
||||
{Object.entries(megaMenus).map(([key, menu]) => (
|
||||
<div key={key} className="space-y-3">
|
||||
<h3 className="font-semibold text-slate-800 text-lg sm:text-xl">{menu.title}</h3>
|
||||
|
||||
{menu.type === 'categories' ? (
|
||||
// Products with Categories - Mobile Responsive
|
||||
<div className="space-y-4">
|
||||
{menu.categories?.map((category: MenuCategory, idx: number) => {
|
||||
const IconComponent = category.icon
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-emerald-600 font-medium text-base sm:text-lg">
|
||||
<IconComponent className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
<div className="pl-6 space-y-1">
|
||||
{category.items.map((item: MenuItem, itemIdx: number) => (
|
||||
<Link
|
||||
key={itemIdx}
|
||||
href={item.href}
|
||||
className="mobile-nav-item block text-base text-slate-600 hover:text-emerald-600 hover:bg-slate-50 transition-colors duration-200 py-2 px-2 rounded-md touch-manipulation"
|
||||
onClick={handleMobileLinkClick}
|
||||
>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{item.description}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Mobile CTA */}
|
||||
<Link href="/contact#quote" onClick={handleMobileLinkClick}>
|
||||
<Button className="w-full bg-emerald-600 text-white hover:bg-emerald-700 rounded-xl px-6 py-4 mt-6 font-medium text-lg touch-manipulation">
|
||||
Get a Quote
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
224
components/reviews/ReviewCard.tsx
Normal file
224
components/reviews/ReviewCard.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Star, ThumbsUp, Flag, MoreHorizontal, Edit, Trash2, Verified } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import Image from 'next/image'
|
||||
import { toast } from 'sonner'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
interface Review {
|
||||
id: string
|
||||
rating: number
|
||||
title?: string
|
||||
comment?: string
|
||||
images: string[]
|
||||
isVerified: boolean
|
||||
helpfulVotes: number
|
||||
createdAt: string
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
image?: string
|
||||
}
|
||||
_count?: {
|
||||
helpfulVotedBy: number
|
||||
}
|
||||
}
|
||||
|
||||
interface ReviewCardProps {
|
||||
review: Review
|
||||
onEdit?: (review: Review) => void
|
||||
onDelete?: (reviewId: string) => void
|
||||
onHelpfulVote?: (reviewId: string) => void
|
||||
onReport?: (reviewId: string) => void
|
||||
userHasVoted?: boolean
|
||||
}
|
||||
|
||||
export default function ReviewCard({
|
||||
review,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onHelpfulVote,
|
||||
onReport,
|
||||
userHasVoted = false
|
||||
}: ReviewCardProps) {
|
||||
const { data: session } = useSession()
|
||||
const [isVoting, setIsVoting] = useState(false)
|
||||
const [hasVoted, setHasVoted] = useState(userHasVoted)
|
||||
const [voteCount, setVoteCount] = useState(review.helpfulVotes)
|
||||
|
||||
const isOwner = session?.user?.id === review.user.id
|
||||
|
||||
const handleHelpfulVote = async () => {
|
||||
if (!session) {
|
||||
toast.error('Please sign in to vote on reviews')
|
||||
return
|
||||
}
|
||||
|
||||
setIsVoting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/reviews/${review.id}/helpful`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
setHasVoted(data.voted)
|
||||
setVoteCount(prev => data.voted ? prev + 1 : prev - 1)
|
||||
toast.success(data.message)
|
||||
onHelpfulVote?.(review.id)
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to vote')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to vote on review')
|
||||
} finally {
|
||||
setIsVoting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReport = () => {
|
||||
if (!session) {
|
||||
toast.error('Please sign in to report reviews')
|
||||
return
|
||||
}
|
||||
onReport?.(review.id)
|
||||
}
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return Array.from({ length: 5 }, (_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`h-4 w-4 ${
|
||||
index < rating
|
||||
? 'text-yellow-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={review.user.image} alt={review.user.name} />
|
||||
<AvatarFallback>
|
||||
{review.user.name?.split(' ').map(n => n[0]).join('') || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-semibold">{review.user.name}</span>
|
||||
{review.isVerified && (
|
||||
<Badge variant="outline" className="text-green-600 border-green-600">
|
||||
<Verified className="h-3 w-3 mr-1" />
|
||||
Verified Purchase
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<div className="flex items-center space-x-1">
|
||||
{renderStars(review.rating)}
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>{formatDistanceToNow(new Date(review.createdAt), { addSuffix: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{isOwner && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit?.(review)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit Review
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete?.(review.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Review
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{review.title && (
|
||||
<h4 className="font-semibold mb-2">{review.title}</h4>
|
||||
)}
|
||||
|
||||
{review.comment && (
|
||||
<p className="text-gray-700 mb-3 leading-relaxed">{review.comment}</p>
|
||||
)}
|
||||
|
||||
{review.images.length > 0 && (
|
||||
<div className="flex space-x-2 mb-3 overflow-x-auto">
|
||||
{review.images.map((image, index) => (
|
||||
<div key={index} className="relative w-20 h-20 flex-shrink-0">
|
||||
<Image
|
||||
src={image}
|
||||
alt={`Review image ${index + 1}`}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleHelpfulVote}
|
||||
disabled={isVoting}
|
||||
className={`${hasVoted ? 'text-green-600' : 'text-gray-600'}`}
|
||||
>
|
||||
<ThumbsUp className={`h-4 w-4 mr-1 ${hasVoted ? 'fill-current' : ''}`} />
|
||||
Helpful ({voteCount})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isOwner && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReport}
|
||||
className="text-gray-600 hover:text-red-600"
|
||||
>
|
||||
<Flag className="h-4 w-4 mr-1" />
|
||||
Report
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
300
components/reviews/ReviewForm.tsx
Normal file
300
components/reviews/ReviewForm.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Star, Upload, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface ReviewFormProps {
|
||||
productId: string
|
||||
productName: string
|
||||
onSubmit?: (review: any) => void
|
||||
onCancel?: () => void
|
||||
disableImageUpload?: boolean
|
||||
existingReview?: {
|
||||
id: string
|
||||
rating: number
|
||||
title?: string
|
||||
comment?: string
|
||||
images: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export default function ReviewForm({
|
||||
productId,
|
||||
productName,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
disableImageUpload = false,
|
||||
existingReview
|
||||
}: ReviewFormProps) {
|
||||
const { data: session } = useSession()
|
||||
const [rating, setRating] = useState(existingReview?.rating || 5)
|
||||
const [title, setTitle] = useState(existingReview?.title || '')
|
||||
const [comment, setComment] = useState(existingReview?.comment || '')
|
||||
const [images, setImages] = useState<string[]>(existingReview?.images || [])
|
||||
const [hoveredStar, setHoveredStar] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [uploadingImage, setUploadingImage] = useState(false)
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<p className="text-gray-600 mb-4">Please sign in to write a review</p>
|
||||
<Button>Sign In</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (images.length >= 5) {
|
||||
toast.error('Maximum 5 images allowed')
|
||||
return
|
||||
}
|
||||
|
||||
setUploadingImage(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('folder', 'reviews')
|
||||
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setImages(prev => [...prev, data.url])
|
||||
toast.success('Image uploaded successfully')
|
||||
} else {
|
||||
toast.error('Failed to upload image')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload image')
|
||||
} finally {
|
||||
setUploadingImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImages(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!rating) {
|
||||
toast.error('Please select a rating')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const url = existingReview
|
||||
? `/api/reviews/${existingReview.id}`
|
||||
: '/api/reviews'
|
||||
|
||||
const method = existingReview ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
rating,
|
||||
title: title.trim() || undefined,
|
||||
comment: comment.trim() || undefined,
|
||||
images
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(data.message || 'Review submitted successfully')
|
||||
onSubmit?.(data.review)
|
||||
|
||||
// Reset form if creating new review
|
||||
if (!existingReview) {
|
||||
setRating(5)
|
||||
setTitle('')
|
||||
setComment('')
|
||||
setImages([])
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to submit review')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to submit review')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderStars = () => {
|
||||
return Array.from({ length: 5 }, (_, index) => {
|
||||
const starValue = index + 1
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => setRating(starValue)}
|
||||
onMouseEnter={() => setHoveredStar(starValue)}
|
||||
onMouseLeave={() => setHoveredStar(0)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<Star
|
||||
className={`h-8 w-8 transition-colors ${
|
||||
starValue <= (hoveredStar || rating)
|
||||
? 'text-yellow-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{existingReview ? 'Edit Your Review' : 'Write a Review'}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-600">for {productName}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Rating */}
|
||||
<div className="space-y-2">
|
||||
<Label>Rating *</Label>
|
||||
<div className="flex items-center space-x-1">
|
||||
{renderStars()}
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
{rating}/5 stars
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Review Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Summarize your experience"
|
||||
maxLength={100}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">{title.length}/100 characters</p>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="comment">Your Review</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Share your thoughts about this product..."
|
||||
rows={4}
|
||||
maxLength={1000}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">{comment.length}/1000 characters</p>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
{!disableImageUpload && (
|
||||
<div className="space-y-2">
|
||||
<Label>Photos (Optional)</Label>
|
||||
<div className="space-y-3">
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{images.map((image, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<Image
|
||||
src={image}
|
||||
alt={`Review image ${index + 1}`}
|
||||
width={80}
|
||||
height={80}
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(index)}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length < 5 && (
|
||||
<div>
|
||||
<label className="cursor-pointer">
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-md p-4 text-center hover:border-gray-400 transition-colors">
|
||||
<Upload className="h-6 w-6 mx-auto mb-2 text-gray-400" />
|
||||
<p className="text-sm text-gray-600">
|
||||
{uploadingImage ? 'Uploading...' : 'Click to upload photos'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Max 5 images, 5MB each
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
disabled={uploadingImage}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !rating}
|
||||
>
|
||||
{isSubmitting
|
||||
? (existingReview ? 'Updating...' : 'Submitting...')
|
||||
: (existingReview ? 'Update Review' : 'Submit Review')
|
||||
}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Your review will be visible after admin approval.
|
||||
Reviews from verified purchases are marked as verified.
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
447
components/reviews/ReviewsList.tsx
Normal file
447
components/reviews/ReviewsList.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Star, Filter, Plus } from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import ReviewCard from './ReviewCard'
|
||||
import ReviewForm from './ReviewForm'
|
||||
import { toast } from 'sonner'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
interface Review {
|
||||
id: string
|
||||
rating: number
|
||||
title?: string
|
||||
comment?: string
|
||||
images: string[]
|
||||
isVerified: boolean
|
||||
helpfulVotes: number
|
||||
createdAt: string
|
||||
userHasVoted?: boolean // Track if current user has voted this review as helpful
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
image?: string
|
||||
}
|
||||
_count?: {
|
||||
helpfulVotedBy: number
|
||||
}
|
||||
}
|
||||
|
||||
interface ReviewsListProps {
|
||||
productId: string
|
||||
productName: string
|
||||
averageRating?: number
|
||||
totalReviews?: number
|
||||
ratingBreakdown?: { [key: number]: number }
|
||||
disableImageUpload?: boolean
|
||||
}
|
||||
|
||||
export default function ReviewsList({
|
||||
productId,
|
||||
productName,
|
||||
averageRating = 0,
|
||||
totalReviews = 0,
|
||||
ratingBreakdown = {},
|
||||
disableImageUpload = false
|
||||
}: ReviewsListProps) {
|
||||
const { data: session } = useSession()
|
||||
const [reviews, setReviews] = useState<Review[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [ratingFilter, setRatingFilter] = useState<string>('all')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingReview, setEditingReview] = useState<Review | null>(null)
|
||||
const [userReview, setUserReview] = useState<Review | null>(null)
|
||||
const [allReviews, setAllReviews] = useState<Review[]>([])
|
||||
const [calculatedStats, setCalculatedStats] = useState({
|
||||
averageRating: 0,
|
||||
totalReviews: 0,
|
||||
ratingBreakdown: {} as { [key: number]: number }
|
||||
})
|
||||
|
||||
const scrollToReviews = () => {
|
||||
const reviewsSection = document.getElementById('reviews-section')
|
||||
if (reviewsSection) {
|
||||
reviewsSection.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
})
|
||||
// If there are no reviews, automatically show the form
|
||||
if (calculatedStats.totalReviews === 0) {
|
||||
setTimeout(() => setShowForm(true), 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const calculateStats = useCallback((allReviewsData: Review[]) => {
|
||||
const total = allReviewsData.length
|
||||
if (total === 0) {
|
||||
setCalculatedStats({
|
||||
averageRating: 0,
|
||||
totalReviews: 0,
|
||||
ratingBreakdown: {}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const sum = allReviewsData.reduce((acc, review) => acc + review.rating, 0)
|
||||
const avg = sum / total
|
||||
|
||||
const breakdown: { [key: number]: number } = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }
|
||||
allReviewsData.forEach(review => {
|
||||
breakdown[review.rating] = (breakdown[review.rating] || 0) + 1
|
||||
})
|
||||
|
||||
setCalculatedStats({
|
||||
averageRating: avg,
|
||||
totalReviews: total,
|
||||
ratingBreakdown: breakdown
|
||||
})
|
||||
}, [])
|
||||
|
||||
const fetchAllReviewsForStats = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
productId,
|
||||
limit: '1000' // Get all reviews for stats
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/reviews?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
setAllReviews(data.reviews)
|
||||
calculateStats(data.reviews)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch all reviews for stats:', error)
|
||||
}
|
||||
}, [productId, calculateStats])
|
||||
|
||||
const fetchReviews = useCallback(async (resetList = false) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
productId,
|
||||
page: resetList ? '1' : page.toString(),
|
||||
limit: '10'
|
||||
})
|
||||
|
||||
if (ratingFilter !== 'all') {
|
||||
params.append('rating', ratingFilter)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/reviews?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
if (resetList) {
|
||||
setReviews(data.reviews)
|
||||
setPage(1)
|
||||
} else {
|
||||
setReviews(prev => [...prev, ...data.reviews])
|
||||
}
|
||||
setHasMore(data.pagination.page < data.pagination.pages)
|
||||
|
||||
// Check if current user has a review
|
||||
if (session?.user) {
|
||||
const userReviewInList = data.reviews.find(
|
||||
(review: Review) => review.user.id === session.user.id
|
||||
)
|
||||
setUserReview(userReviewInList || null)
|
||||
}
|
||||
|
||||
// If this is the first page, also fetch all reviews for stats
|
||||
if (resetList) {
|
||||
fetchAllReviewsForStats()
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to load reviews')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to load reviews')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [productId, page, ratingFilter, session?.user, fetchAllReviewsForStats])
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchReviews(true)
|
||||
}, [fetchReviews])
|
||||
|
||||
const loadMore = () => {
|
||||
setPage(prev => prev + 1)
|
||||
fetchReviews()
|
||||
}
|
||||
|
||||
const handleReviewSubmit = (newReview: Review) => {
|
||||
setShowForm(false)
|
||||
setEditingReview(null)
|
||||
setUserReview(newReview)
|
||||
fetchReviews(true) // Refresh the list
|
||||
}
|
||||
|
||||
const handleEdit = (review: Review) => {
|
||||
setEditingReview(review)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (reviewId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this review?')) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/reviews/${reviewId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Review deleted successfully')
|
||||
setReviews(prev => prev.filter(review => review.id !== reviewId))
|
||||
setUserReview(null)
|
||||
} else {
|
||||
const data = await response.json()
|
||||
toast.error(data.error || 'Failed to delete review')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete review')
|
||||
}
|
||||
}
|
||||
|
||||
const handleHelpfulVote = async (reviewId: string) => {
|
||||
if (!session?.user) {
|
||||
toast.error('Please sign in to vote')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/reviews/${reviewId}/helpful`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
|
||||
// Update the review in the list with new vote status and count
|
||||
setReviews(prev => prev.map(review =>
|
||||
review.id === reviewId
|
||||
? {
|
||||
...review,
|
||||
userHasVoted: data.hasVoted,
|
||||
helpfulVotes: data.totalVotes,
|
||||
_count: {
|
||||
...review._count,
|
||||
helpfulVotedBy: data.totalVotes
|
||||
}
|
||||
}
|
||||
: review
|
||||
))
|
||||
|
||||
toast.success(data.hasVoted ? 'Marked as helpful' : 'Removed helpful vote')
|
||||
} else {
|
||||
const data = await response.json()
|
||||
toast.error(data.error || 'Failed to vote')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to vote')
|
||||
}
|
||||
}
|
||||
|
||||
const renderRatingBreakdown = () => {
|
||||
if (!calculatedStats.totalReviews) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[5, 4, 3, 2, 1].map(rating => {
|
||||
const count = calculatedStats.ratingBreakdown[rating] || 0
|
||||
const percentage = calculatedStats.totalReviews > 0 ? (count / calculatedStats.totalReviews) * 100 : 0
|
||||
|
||||
return (
|
||||
<div key={rating} className="flex items-center space-x-2 text-sm">
|
||||
<div className="flex items-center space-x-1 w-16">
|
||||
<span>{rating}</span>
|
||||
<Star className="h-3 w-3 text-yellow-400 fill-current" />
|
||||
</div>
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-yellow-400 h-2 rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right">{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="reviews-section" className="space-y-6">
|
||||
{/* Reviews Summary - Only show if there are reviews */}
|
||||
{calculatedStats.totalReviews > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Customer Reviews</span>
|
||||
{session && !userReview && !showForm && (
|
||||
<Button onClick={scrollToReviews}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Write Review
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Overall Rating */}
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold mb-2">{calculatedStats.averageRating.toFixed(1)}</div>
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`h-5 w-5 ${
|
||||
index < Math.round(calculatedStats.averageRating)
|
||||
? 'text-yellow-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-600">{calculatedStats.totalReviews} reviews</p>
|
||||
</div>
|
||||
|
||||
{/* Rating Breakdown */}
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">Rating Breakdown</h4>
|
||||
{renderRatingBreakdown()}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* No Reviews State - Show when there are no reviews */}
|
||||
{calculatedStats.totalReviews === 0 && !loading && (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className="h-8 w-8 text-gray-300"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No reviews yet</h3>
|
||||
<p className="text-gray-600 mb-4">Be the first to review this product</p>
|
||||
{session ? (
|
||||
<Button onClick={() => setShowForm(true)} className="bg-emerald-600 hover:bg-emerald-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Write First Review
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Sign in to write a review</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Review Form */}
|
||||
{showForm && (
|
||||
<ReviewForm
|
||||
productId={productId}
|
||||
productName={productName}
|
||||
existingReview={editingReview || undefined}
|
||||
disableImageUpload={disableImageUpload}
|
||||
onSubmit={handleReviewSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setEditingReview(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filters and Reviews List */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">All Reviews ({calculatedStats.totalReviews})</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4 text-gray-500" />
|
||||
<Select value={ratingFilter} onValueChange={setRatingFilter}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="Filter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Ratings</SelectItem>
|
||||
<SelectItem value="5">5 Stars</SelectItem>
|
||||
<SelectItem value="4">4 Stars</SelectItem>
|
||||
<SelectItem value="3">3 Stars</SelectItem>
|
||||
<SelectItem value="2">2 Stars</SelectItem>
|
||||
<SelectItem value="1">1 Star</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reviews */}
|
||||
{loading && reviews.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Loading reviews...</p>
|
||||
</div>
|
||||
) : reviews.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600 mb-4">No reviews yet</p>
|
||||
{session && !userReview && (
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
Be the first to review
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{reviews.map((review) => (
|
||||
<ReviewCard
|
||||
key={review.id}
|
||||
review={review}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onHelpfulVote={handleHelpfulVote}
|
||||
userHasVoted={review.userHasVoted || false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<div className="text-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadMore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Loading...' : 'Load More Reviews'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
184
components/sections/AboutSection.tsx
Normal file
184
components/sections/AboutSection.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Wheat, Shield, Award, ArrowRight, Star, CheckCircle, Users, TrendingUp } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export default function AboutSection() {
|
||||
const stats = [
|
||||
{ number: "100%", label: "Customer Satisfaction", icon: Award, color: "from-amber-500 to-yellow-600" },
|
||||
{ number: "10K+", label: "Rice Loving Families", icon: Users, color: "from-emerald-500 to-green-600" },
|
||||
{ number: "8000+", label: "Tons Processed Monthly", icon: TrendingUp, color: "from-orange-500 to-red-600" },
|
||||
{ number: "99%", label: "Pure & Natural Rice", icon: TrendingUp, color: "from-yellow-500 to-amber-600" }
|
||||
]
|
||||
|
||||
const features = [
|
||||
"Direct sourcing from Punjab & Haryana rice belt",
|
||||
"Traditional aging process for Basmati rice",
|
||||
"Steam processing for premium Sella varieties",
|
||||
"Farm-to-table traceability and quality assurance"
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="relative py-12 md:py-32 overflow-hidden">
|
||||
{/* Modern gradient background with rice-inspired colors */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-amber-50 via-yellow-50 to-orange-50/30"></div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(245,158,11,0.05),transparent_50%)]"></div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_80%,rgba(251,191,36,0.05),transparent_50%)]"></div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
{/* Section header with modern typography */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-8 md:mb-20"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 bg-amber-100/50 backdrop-blur-sm px-4 py-2 rounded-full mb-6">
|
||||
<Wheat className="w-4 h-4 text-amber-600" />
|
||||
<span className="text-amber-700 font-medium text-sm">Our Rice Heritage</span>
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 mb-4">
|
||||
From Golden Fields to {''}
|
||||
<span className="text-transparent bg-gradient-to-r from-amber-600 via-yellow-500 to-orange-500 bg-clip-text">
|
||||
Your Kitchen
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-lg text-slate-600 max-w-3xl mx-auto leading-relaxed">
|
||||
Bringing you the finest rice varieties from the fertile plains of Northern India, aged to perfection and processed with traditional care
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Main content grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 sm:gap-12 lg:gap-20 items-center mb-16 sm:mb-20">
|
||||
{/* Visual side - First on mobile, second on desktop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="relative order-1 lg:order-2"
|
||||
>
|
||||
{/* Main image with modern styling - 16:9 on mobile, original aspect on desktop */}
|
||||
<div className="relative rounded-3xl overflow-hidden shadow-2xl aspect-video lg:aspect-auto">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-600/20 via-transparent to-orange-600/20 z-10"></div>
|
||||
<Image
|
||||
src="/farmer.png"
|
||||
alt="Premium food processing facility"
|
||||
width={600}
|
||||
height={500}
|
||||
className="w-full lg:h-[500px] object-cover"
|
||||
priority
|
||||
/>
|
||||
{/* Floating quality badge */}
|
||||
<div className="absolute top-4 left-4 sm:top-6 sm:left-6 bg-white/95 backdrop-blur-sm rounded-2xl px-3 py-2 sm:px-4 sm:py-3 shadow-lg z-20">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="font-semibold text-slate-900 text-xs sm:text-sm">Premium Certified</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute -top-6 -right-6 w-24 h-24 bg-gradient-to-br from-orange-400 to-orange-500 rounded-full opacity-20 blur-xl"></div>
|
||||
<div className="absolute -bottom-4 -left-4 w-20 h-20 bg-gradient-to-br from-emerald-400 to-emerald-500 rounded-full opacity-20 blur-xl"></div>
|
||||
</motion.div>
|
||||
|
||||
{/* Content side - Second on mobile, first on desktop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="space-y-6 sm:space-y-8 order-2 lg:order-1"
|
||||
>
|
||||
{/* Story */}
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<h3 className="text-xl sm:text-2xl md:text-3xl font-bold text-slate-900 leading-tight">
|
||||
Two Decades of
|
||||
<span className="text-emerald-600"> Trusted Excellence</span>
|
||||
</h3>
|
||||
<div className="space-y-3 sm:space-y-4 text-base sm:text-lg leading-relaxed text-slate-600">
|
||||
<p>
|
||||
Padmaaja Rasooi has revolutionized food processing with unwavering commitment to quality and authenticity.
|
||||
</p>
|
||||
<p>
|
||||
We've built lasting relationships with farmers, ensuring every product reflects our dedication to
|
||||
excellence and the highest standards of food safety.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature list */}
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: 0.3 + index * 0.1 }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center flex-shrink-0">
|
||||
<CheckCircle className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-white" />
|
||||
</div>
|
||||
<span className="text-slate-700 font-medium text-sm sm:text-base">{feature}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="pt-3"
|
||||
>
|
||||
<Button asChild size="lg" className="w-full sm:w-auto bg-gradient-to-r from-emerald-600 to-emerald-700 hover:from-emerald-700 hover:to-emerald-800 text-white px-6 sm:px-8 py-3 sm:py-4 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 group">
|
||||
<Link href="/about" className="inline-flex items-center justify-center text-sm sm:text-lg">
|
||||
Discover Our Story
|
||||
<ArrowRight className="ml-2 h-4 w-4 sm:h-5 sm:w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Stats section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
className="grid grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8"
|
||||
>
|
||||
{stats.map((stat, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: 0.5 + index * 0.1 }}
|
||||
className="relative group"
|
||||
>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl p-4 sm:p-6 lg:p-8 shadow-lg hover:shadow-xl transition-all duration-300 border border-white/50 group-hover:scale-105">
|
||||
<div className={`w-8 h-8 sm:w-10 sm:h-10 lg:w-12 lg:h-12 bg-gradient-to-br ${stat.color} rounded-lg sm:rounded-xl flex items-center justify-center mb-3 sm:mb-4 group-hover:scale-110 transition-transform`}>
|
||||
<stat.icon className="w-4 h-4 sm:w-5 sm:h-5 lg:w-6 lg:h-6 text-white" />
|
||||
</div>
|
||||
<div className="text-xl sm:text-2xl lg:text-3xl xl:text-4xl font-bold text-slate-900 mb-1 sm:mb-2">{stat.number}</div>
|
||||
<div className="text-slate-600 font-medium text-xs sm:text-sm lg:text-base leading-tight">{stat.label}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
115
components/sections/CertificationsSection.tsx
Normal file
115
components/sections/CertificationsSection.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export default function CertificationsSection() {
|
||||
const certifications = [
|
||||
{
|
||||
id: 1,
|
||||
name: "FSSAI",
|
||||
image: "/certifications/Frame-1000003749.png",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "ISO",
|
||||
image: "/certifications/Frame-1000003750.png",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "GMP",
|
||||
image: "/certifications/Frame-1000003751.png",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "HACCP",
|
||||
image: "/certifications/image-371-1.png",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "FDA",
|
||||
image: "/certifications/image-375-1.png",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "BRC",
|
||||
image: "/certifications/image-377-2.png",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Quality",
|
||||
image: "/certifications/image-92.png",
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="relative py-12 md:py-16 overflow-hidden bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-8 md:mb-12"
|
||||
>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2">
|
||||
<span className="text-transparent bg-gradient-to-r from-red-600 via-red-500 to-orange-500 bg-clip-text">
|
||||
Certified By
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-slate-600 max-w-2xl mx-auto">
|
||||
Our commitment to quality is validated by prestigious international certifications
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Certifications Row */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="flex flex-wrap justify-center items-center gap-6 md:gap-8 lg:gap-12"
|
||||
>
|
||||
{certifications.map((cert, index) => (
|
||||
<motion.div
|
||||
key={cert.id}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="group cursor-pointer"
|
||||
>
|
||||
<div className="relative w-20 h-20 md:w-24 md:h-24 lg:w-28 lg:h-28 bg-white rounded-full shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 group-hover:border-gray-200 flex items-center justify-center p-3">
|
||||
<Image
|
||||
src={cert.image}
|
||||
alt={cert.name}
|
||||
fill
|
||||
className="object-contain p-2 group-hover:scale-110 transition-transform duration-300"
|
||||
sizes="(max-width: 768px) 80px, (max-width: 1024px) 96px, 112px"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-xs md:text-sm font-medium text-gray-600 mt-2 group-hover:text-gray-900 transition-colors">
|
||||
{cert.name}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Bottom Text */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.5 }}
|
||||
className="text-center mt-8 md:mt-12"
|
||||
>
|
||||
<p className="text-sm text-gray-500 max-w-xl mx-auto">
|
||||
Trusted by industry leaders worldwide for our commitment to quality, safety, and excellence in food production.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
36
components/sections/ClientPageWrapper.tsx
Normal file
36
components/sections/ClientPageWrapper.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import PageLoader from '@/components/ui/page-loader'
|
||||
|
||||
interface ClientPageWrapperProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function ClientPageWrapper({ children }: ClientPageWrapperProps) {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const handleLoadingComplete = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
{isLoading && (
|
||||
<PageLoader
|
||||
onLoadingComplete={handleLoadingComplete}
|
||||
duration={2000}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{!isLoading && (
|
||||
<div className="animate-in fade-in duration-500">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
349
components/sections/HeroSection.tsx
Normal file
349
components/sections/HeroSection.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronLeft, ChevronRight, Wheat } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface Banner {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string
|
||||
description: string
|
||||
image: string
|
||||
cta: string
|
||||
ctaLink?: string
|
||||
bgColor?: string
|
||||
}
|
||||
|
||||
interface FeatureCard {
|
||||
icon: any
|
||||
title: string
|
||||
description: string
|
||||
link: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export default function HeroSection() {
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const [imageLoading, setImageLoading] = useState(true)
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null)
|
||||
const [touchEnd, setTouchEnd] = useState<number | null>(null)
|
||||
|
||||
const banners: Banner[] = useMemo(() => [
|
||||
{
|
||||
id: 1,
|
||||
title: "Premium Aged Basmati 1121",
|
||||
subtitle: "Extra Long Grain • 2+ Years Aged",
|
||||
description: "Authentic Basmati rice with exceptional length, distinctive aroma, and fluffy texture from the foothills of Himalayas",
|
||||
image: "/images/rice-hero-slider.jpg",
|
||||
cta: "Shop Premium Rice",
|
||||
ctaLink: "/products?category=rice",
|
||||
bgColor: "from-amber-600 via-yellow-600 to-orange-700"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Kashmina Brand Collection",
|
||||
subtitle: "Farm Fresh • Direct from Fields",
|
||||
description: "Premium rice varieties sourced directly from trusted farmers, processed with care for exceptional quality",
|
||||
image: "https://4m5m4tx28rtva30c.public.blob.vercel-storage.com/media/2025-09-07/banner-slider-1",
|
||||
cta: "Shop Kashmina",
|
||||
ctaLink: "/products?brand=kashmina",
|
||||
bgColor: "from-emerald-600 via-green-600 to-yellow-600"
|
||||
},
|
||||
// {
|
||||
// id: 4,
|
||||
// title: "Wholesale Rice Supplies",
|
||||
// subtitle: "Bulk Orders • Competitive Rates",
|
||||
// description: "Reliable wholesale rice supplier offering bulk quantities of premium Basmati and Sella rice for businesses and retailers",
|
||||
// image: "https://4m5m4tx28rtva30c.public.blob.vercel-storage.com/women-carrying-rice",
|
||||
// cta: "Shop Wholesale",
|
||||
// ctaLink: "/wholesaler",
|
||||
// bgColor: "from-orange-600 via-red-600 to-amber-600"
|
||||
// },
|
||||
{
|
||||
id: 5,
|
||||
title: "Indian Frmer Plowing",
|
||||
subtitle: "Traditional Farming • Heritage Grain",
|
||||
description: "Experience the rich heritage of rice cultivation with traditional farming methods passed down through generations.",
|
||||
image: "https://4m5m4tx28rtva30c.public.blob.vercel-storage.com/media/2025-09-07/indian-farmer-plowing",
|
||||
cta: "Learn More",
|
||||
}
|
||||
], [])
|
||||
|
||||
// Debug: Log current slide image
|
||||
useEffect(() => {
|
||||
console.log('Current slide image:', banners[currentSlide]?.image);
|
||||
}, [currentSlide, banners]);
|
||||
|
||||
const featuredCategories = [
|
||||
{
|
||||
title: "Kashmina Sella Premium",
|
||||
subtitle: "Extra long grain",
|
||||
link: "/products?brand=basmati-1121",
|
||||
bgColor: "from-amber-500 to-yellow-600"
|
||||
},
|
||||
{
|
||||
title: "Kashmina Gold Premium",
|
||||
subtitle: "Steam processed",
|
||||
link: "/products?category=sella",
|
||||
bgColor: "from-yellow-500 to-amber-600"
|
||||
},
|
||||
{
|
||||
title: "Kashmina Steam Premium",
|
||||
subtitle: "Traditional quality",
|
||||
link: "/products?brand=kashmina",
|
||||
bgColor: "from-emerald-500 to-green-600"
|
||||
},
|
||||
{
|
||||
title: "Wholesale",
|
||||
subtitle: "Wholesale rates",
|
||||
link: "/wholesaler",
|
||||
bgColor: "from-orange-500 to-red-600"
|
||||
}
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentSlide((prev) => (prev + 1) % banners.length)
|
||||
}, 5000) // Auto-slide every 5 seconds
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [banners.length])
|
||||
|
||||
// Reset loading state when slide changes
|
||||
useEffect(() => {
|
||||
setImageLoading(true)
|
||||
}, [currentSlide])
|
||||
|
||||
const nextSlide = () => {
|
||||
setCurrentSlide((prev) => (prev + 1) % banners.length)
|
||||
}
|
||||
|
||||
const prevSlide = () => {
|
||||
setCurrentSlide((prev) => (prev - 1 + banners.length) % banners.length)
|
||||
}
|
||||
|
||||
const goToSlide = (index: number) => {
|
||||
setCurrentSlide(index)
|
||||
}
|
||||
|
||||
// Touch handlers for mobile swipe
|
||||
const minSwipeDistance = 50
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
setTouchEnd(null) // otherwise the swipe is fired even with usual touch events
|
||||
setTouchStart(e.targetTouches[0].clientX)
|
||||
}
|
||||
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
setTouchEnd(e.targetTouches[0].clientX)
|
||||
}
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (!touchStart || !touchEnd) return
|
||||
const distance = touchStart - touchEnd
|
||||
const isLeftSwipe = distance > minSwipeDistance
|
||||
const isRightSwipe = distance < -minSwipeDistance
|
||||
|
||||
if (isLeftSwipe) {
|
||||
nextSlide()
|
||||
} else if (isRightSwipe) {
|
||||
prevSlide()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative bg-gradient-to-br from-amber-50/50 via-yellow-50/30 to-orange-50/20">
|
||||
|
||||
{/* Amazon-style Hero Layout */}
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-6 lg:px-8 py-4 lg:py-12">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-5 lg:gap-6">
|
||||
|
||||
{/* Main Banner Slider - Left Side (3/4 width) - Better mobile aspect ratio */}
|
||||
<div
|
||||
className="lg:col-span-3 relative overflow-hidden rounded-lg shadow-lg aspect-[16/10] md:aspect-video isolate"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Skeleton Loader for CLS prevention */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-200 to-gray-300 animate-pulse z-0" />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentSlide}
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -30 }}
|
||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||
className="absolute inset-0 isolate"
|
||||
>
|
||||
{/* Background Color Fallback */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${banners[currentSlide].bgColor ?? 'from-amber-600 via-yellow-600 to-orange-700'} z-0`}></div>
|
||||
|
||||
<Image
|
||||
src={banners[currentSlide].image}
|
||||
alt={banners[currentSlide].title}
|
||||
fill
|
||||
className="object-cover z-10"
|
||||
priority={currentSlide === 0} // Only prioritize first image
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 75vw"
|
||||
quality={85} // Optimize quality vs file size
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
|
||||
onLoadStart={() => setImageLoading(true)}
|
||||
onError={(e) => {
|
||||
console.error('Image failed to load:', banners[currentSlide].image);
|
||||
setImageLoading(false);
|
||||
// Keep the gradient background visible on error
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('Image loaded successfully:', banners[currentSlide].image);
|
||||
setImageLoading(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Loading overlay */}
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 z-15 flex items-center justify-center">
|
||||
<div className="animate-pulse">
|
||||
<div className="w-8 h-8 bg-white/30 rounded-full animate-bounce"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content positioned in center-left with enhanced visibility */}
|
||||
<div className="absolute inset-0 z-20 flex items-center">
|
||||
<div className="max-w-lg px-4 sm:px-6 md:px-8 lg:px-10 xl:px-12 w-full">
|
||||
{/* Semi-transparent background for text */}
|
||||
{/* <div className="bg-black/40 backdrop-blur-sm rounded-xl md:rounded-2xl p-4 sm:p-5 md:p-6 lg:p-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="space-y-2 sm:space-y-3 md:space-y-4"
|
||||
>
|
||||
<p className="text-yellow-300 font-semibold text-sm sm:text-base md:text-lg drop-shadow-lg">
|
||||
{banners[currentSlide].subtitle}
|
||||
</p>
|
||||
<h1 className="text-lg sm:text-xl md:text-2xl lg:text-3xl xl:text-4xl 2xl:text-5xl font-bold text-white leading-tight drop-shadow-2xl">
|
||||
{banners[currentSlide].title}
|
||||
</h1>
|
||||
<p className="text-white text-sm sm:text-base md:text-lg leading-relaxed max-w-xs sm:max-w-sm md:max-w-md drop-shadow-lg">
|
||||
{banners[currentSlide].description}
|
||||
</p>
|
||||
<div className="pt-3 sm:pt-4">
|
||||
<Button asChild size="default" className="bg-white text-gray-900 hover:bg-gray-100 text-sm sm:text-base font-medium px-5 sm:px-6 md:px-8 py-2.5 sm:py-3 rounded-lg shadow-lg">
|
||||
<Link href={banners[currentSlide].ctaLink ?? '/'}>
|
||||
{banners[currentSlide].cta}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Subtle Navigation Arrows - Better mobile visibility */}
|
||||
<button
|
||||
onClick={prevSlide}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-30 bg-white/30 hover:bg-white/40 text-white p-2 rounded-full transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextSlide}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-30 bg-white/30 hover:bg-white/40 text-white p-2 rounded-full transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
|
||||
{/* Slide Indicators - Fixed positioning with background */}
|
||||
<div className="hidden sm:flex absolute bottom-4 left-1/2 -translate-x-1/2 z-40 space-x-1.5 bg-black/20 backdrop-blur-sm px-2.5 py-1.5 rounded-full">
|
||||
{banners.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => goToSlide(index)}
|
||||
className={`!w-1.5 !h-1.5 sm:w-2 !sm:h-2 !md:w-2.5 !md:h-2.5 rounded-full transition-all duration-200 ${
|
||||
index === currentSlide
|
||||
? 'bg-white shadow-lg'
|
||||
: 'bg-white/60 hover:bg-white/80'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modern Category Cards - Right Side (1/4 width) - Ultra compact mobile */}
|
||||
<div className="lg:col-span-1 aspect-[4/3] sm:aspect-[16/10] md:aspect-video lg:aspect-auto">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-1.5 sm:gap-2 lg:gap-3 h-full">
|
||||
{featuredCategories.slice(0, 4).map((category, index) => (
|
||||
<motion.div
|
||||
key={category.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||
className="min-h-[50px] sm:min-h-[70px] lg:h-full lg:min-h-0"
|
||||
>
|
||||
<Link href={category.link}>
|
||||
<div className="group h-full relative bg-white rounded-md border border-gray-200 hover:border-emerald-300 shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer overflow-hidden">
|
||||
{/* Gradient Background Accent */}
|
||||
<div className={`absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r ${category.bgColor}`}></div>
|
||||
|
||||
{/* Content - Ultra Compact Mobile Layout */}
|
||||
<div className="relative px-2 py-1.5 sm:px-3 sm:py-2 lg:p-4 h-full flex items-center gap-2 sm:gap-3">
|
||||
{/* Icon Container - Very small on mobile */}
|
||||
<div className={`w-6 h-6 sm:w-8 sm:h-8 lg:w-10 lg:h-10 rounded-md bg-gradient-to-br ${category.bgColor} flex items-center justify-center group-hover:scale-105 transition-all duration-300 shadow-sm flex-shrink-0`}>
|
||||
<Wheat className="w-3 h-3 sm:w-4 sm:h-4 lg:w-5 lg:h-5 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Typography - Ultra compact */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-xs sm:text-sm lg:text-base text-gray-900 group-hover:text-emerald-600 transition-colors leading-tight mb-0.5">
|
||||
{category.title}
|
||||
</h3>
|
||||
<p className="text-[10px] sm:text-xs lg:text-sm text-gray-600 group-hover:text-gray-700 transition-colors">
|
||||
{category.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon - Hidden on mobile */}
|
||||
<div className="hidden lg:block opacity-0 group-hover:opacity-100 transition-all duration-300 flex-shrink-0">
|
||||
<ChevronRight className="w-4 h-4 text-gray-400 group-hover:text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary Strip - Repositioned and Mobile Optimized */}
|
||||
{/* <div className="bg-gradient-to-r from-amber-600 via-yellow-600 to-orange-600 text-white py-4 sm:py-5 md:py-6 mt-4 sm:mt-6">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-6 lg:px-8">
|
||||
<div className="flex flex-col items-center justify-center text-center gap-3 sm:gap-4">
|
||||
<div className="flex-1 max-w-4xl">
|
||||
<h3 className="text-sm sm:text-base md:text-lg font-semibold mb-1 sm:mb-2">🌾 Premium Rice • Direct from Farms • Free Shipping ₹500+</h3>
|
||||
<p className="text-yellow-100 text-xs sm:text-sm md:text-base">Aged Basmati & Golden Sella rice delivered fresh to your kitchen</p>
|
||||
</div>
|
||||
<Link href="/products">
|
||||
<Button variant="outline" size="sm" className="bg-white text-amber-700 hover:bg-yellow-50 border-white text-xs sm:text-sm font-medium px-4 sm:px-6 py-2 sm:py-2.5 rounded-lg shadow-lg hover:shadow-xl transition-all duration-300">
|
||||
Explore Rice Varieties
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
238
components/sections/KashminaSection.tsx
Normal file
238
components/sections/KashminaSection.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Star, Award, Truck, ShieldCheck, ArrowRight, Sparkles, Wheat, Heart } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export default function KashminaSection() {
|
||||
return (
|
||||
<section className="relative py-16 lg:py-24 overflow-hidden">
|
||||
{/* Enhanced Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-amber-50 via-yellow-50 to-orange-50"></div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(245,158,11,0.08),transparent_50%)]"></div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_80%,rgba(251,191,36,0.08),transparent_50%)]"></div>
|
||||
|
||||
{/* Animated Background Elements */}
|
||||
<div className="absolute top-20 left-10 w-32 h-32 bg-amber-200/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-20 right-10 w-40 h-40 bg-yellow-200/20 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
{/* Enhanced Section Header */}
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 bg-gradient-to-r from-amber-100 to-yellow-100 backdrop-blur-sm px-6 py-3 rounded-full mb-6 border border-amber-200/50 shadow-lg">
|
||||
<Sparkles className="w-5 h-5 text-amber-600 animate-pulse" />
|
||||
<span className="text-amber-700 font-semibold text-sm tracking-wide">PREMIUM RICE BRAND</span>
|
||||
<Sparkles className="w-5 h-5 text-orange-600 animate-pulse delay-500" />
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
<span className="text-transparent bg-gradient-to-r from-amber-600 via-yellow-600 to-orange-600 bg-clip-text bg-300% animate-gradient">
|
||||
Kashmina
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-gray-800 text-3xl md:text-4xl lg:text-5xl font-medium">Premium Basmati Rice</span>
|
||||
</h2>
|
||||
<p className="text-xl md:text-2xl text-gray-600 max-w-4xl mx-auto leading-relaxed font-light">
|
||||
Authentic aged Basmati rice with <span className="text-amber-600 font-medium">extraordinary length, exquisite aroma, and royal taste</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Features Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto mb-20">
|
||||
{/* Premium Quality */}
|
||||
<div className="group cursor-pointer">
|
||||
<div className="relative bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 p-8 text-center border border-white/50 group-hover:border-emerald-300/50 group-hover:-translate-y-2 group-hover:bg-white/90 overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-emerald-100 to-emerald-200 rounded-full -translate-y-10 translate-x-10 opacity-50 group-hover:scale-150 transition-transform duration-700"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 group-hover:rotate-3 transition-all duration-500 shadow-lg shadow-emerald-200">
|
||||
<Wheat className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3 group-hover:text-emerald-600 transition-colors duration-300">Authentic Grains</h3>
|
||||
<p className="text-gray-600 text-sm leading-relaxed mb-4">
|
||||
Treasured grain with distinct aroma and great nutty taste
|
||||
</p>
|
||||
<div className="inline-block bg-gradient-to-r from-emerald-100 to-emerald-200 text-emerald-700 px-4 py-2 rounded-full text-xs font-bold tracking-wide">
|
||||
AUTHENTIC QUALITY
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Quality */}
|
||||
<div className="group cursor-pointer">
|
||||
<div className="relative bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 p-8 text-center border border-white/50 group-hover:border-blue-300/50 group-hover:-translate-y-2 group-hover:bg-white/90 overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-blue-100 to-blue-200 rounded-full -translate-y-10 translate-x-10 opacity-50 group-hover:scale-150 transition-transform duration-700"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-blue-400 to-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 group-hover:rotate-3 transition-all duration-500 shadow-lg shadow-blue-200">
|
||||
<Award className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3 group-hover:text-blue-600 transition-colors duration-300">Export Grade</h3>
|
||||
<p className="text-gray-600 text-sm leading-relaxed mb-4">
|
||||
International quality standards for global markets
|
||||
</p>
|
||||
<div className="inline-block bg-gradient-to-r from-blue-100 to-blue-200 text-blue-700 px-4 py-2 rounded-full text-xs font-bold tracking-wide">
|
||||
CERTIFIED
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Tested */}
|
||||
<div className="group cursor-pointer">
|
||||
<div className="relative bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 p-8 text-center border border-white/50 group-hover:border-purple-300/50 group-hover:-translate-y-2 group-hover:bg-white/90 overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-purple-100 to-purple-200 rounded-full -translate-y-10 translate-x-10 opacity-50 group-hover:scale-150 transition-transform duration-700"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-purple-400 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 group-hover:rotate-3 transition-all duration-500 shadow-lg shadow-purple-200">
|
||||
<ShieldCheck className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3 group-hover:text-purple-600 transition-colors duration-300">Lab Tested</h3>
|
||||
<p className="text-gray-600 text-sm leading-relaxed mb-4">
|
||||
Rigorously tested for purity and quality assurance
|
||||
</p>
|
||||
<div className="inline-block bg-gradient-to-r from-purple-100 to-purple-200 text-purple-700 px-4 py-2 rounded-full text-xs font-bold tracking-wide">
|
||||
100% PURE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fresh Packaging */}
|
||||
<div className="group cursor-pointer">
|
||||
<div className="relative bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 p-8 text-center border border-white/50 group-hover:border-orange-300/50 group-hover:-translate-y-2 group-hover:bg-white/90 overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-orange-100 to-orange-200 rounded-full -translate-y-10 translate-x-10 opacity-50 group-hover:scale-150 transition-transform duration-700"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-orange-400 to-orange-600 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 group-hover:rotate-3 transition-all duration-500 shadow-lg shadow-orange-200">
|
||||
<Truck className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3 group-hover:text-orange-600 transition-colors duration-300">Fresh Pack</h3>
|
||||
<p className="text-gray-600 text-sm leading-relaxed mb-4">
|
||||
Sealed for maximum freshness and aroma preservation
|
||||
</p>
|
||||
<div className="inline-block bg-gradient-to-r from-orange-100 to-orange-200 text-orange-700 px-4 py-2 rounded-full text-xs font-bold tracking-wide">
|
||||
FRESH SEALED
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Product Showcase */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0 items-center">
|
||||
{/* Enhanced Product Image */}
|
||||
<div className="relative order-2 lg:order-1">
|
||||
<div className="relative p-12">
|
||||
{/* Floating particles */}
|
||||
<div className="absolute top-4 left-4 w-2 h-2 bg-emerald-400 rounded-full animate-ping"></div>
|
||||
<div className="absolute top-12 right-8 w-1 h-1 bg-blue-400 rounded-full animate-ping delay-1000"></div>
|
||||
<div className="absolute bottom-8 left-8 w-1.5 h-1.5 bg-purple-400 rounded-full animate-ping delay-2000"></div>
|
||||
|
||||
<Image
|
||||
src="/rice_bags.png"
|
||||
alt="Kashmina Premium Basmati Rice"
|
||||
width={500}
|
||||
height={400}
|
||||
className="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-700 drop-shadow-2xl"
|
||||
priority
|
||||
/>
|
||||
|
||||
{/* Enhanced Floating Badges */}
|
||||
<div className="absolute top-8 left-8 bg-white/95 backdrop-blur-md rounded-2xl px-6 py-4 shadow-xl border border-emerald-200/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-4 h-4 bg-gradient-to-r from-emerald-400 to-emerald-600 rounded-full animate-pulse shadow-lg shadow-emerald-200"></div>
|
||||
<div>
|
||||
<span className="font-bold text-gray-900 text-sm block">Premium Quality</span>
|
||||
<span className="text-emerald-600 text-xs font-medium">Export Grade</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Rating Badge */}
|
||||
{/* <div className="absolute top-8 right-8 bg-white/95 backdrop-blur-md rounded-2xl px-6 py-4 shadow-xl border border-yellow-200/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-bold text-gray-900 block">4.9/5</span>
|
||||
<span className="text-xs text-gray-600">2.5k+ Reviews</span>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* Background decorations */}
|
||||
<div className="absolute -top-6 -right-6 w-32 h-32 bg-gradient-to-br from-emerald-300 to-blue-400 rounded-full opacity-10 blur-2xl animate-pulse"></div>
|
||||
<div className="absolute -bottom-6 -left-6 w-28 h-28 bg-gradient-to-br from-purple-300 to-pink-400 rounded-full opacity-10 blur-2xl animate-pulse delay-1000"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Content */}
|
||||
<div className="space-y-8 order-1 lg:order-2">
|
||||
<div>
|
||||
<h3 className="text-xl sm:text-2xl md:text-3xl font-bold text-slate-900 leading-tight mb-6">
|
||||
Crafted for {' '}
|
||||
<span className="text-transparent bg-gradient-to-r from-emerald-600 via-blue-600 to-purple-600 bg-clip-text">
|
||||
Perfection
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-base sm:text-lg leading-relaxed text-slate-600 mb-8">
|
||||
Kashmina represents the pinnacle of basmati rice quality, carefully selected
|
||||
from the finest grains and processed with precision to deliver an
|
||||
<span className="text-emerald-600 font-medium">exceptional culinary experience</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Key Features */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-4 p-4 bg-white/60 backdrop-blur-sm rounded-xl border border-emerald-200/30 hover:bg-white/80 hover:border-emerald-300/50 transition-all duration-300 group">
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-500 flex-shrink-0"></div>
|
||||
<span className="text-slate-700 font-medium text-sm sm:text-base group-hover:text-slate-900 transition-colors">Premium long-grain basmati rice</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-4 bg-white/60 backdrop-blur-sm rounded-xl border border-blue-200/30 hover:bg-white/80 hover:border-blue-300/50 transition-all duration-300 group">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500 flex-shrink-0"></div>
|
||||
<span className="text-slate-700 font-medium text-sm sm:text-base group-hover:text-slate-900 transition-colors">Natural aroma and superior taste</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-4 bg-white/60 backdrop-blur-sm rounded-xl border border-purple-200/30 hover:bg-white/80 hover:border-purple-300/50 transition-all duration-300 group">
|
||||
<div className="w-3 h-3 rounded-full bg-purple-500 flex-shrink-0"></div>
|
||||
<span className="text-slate-700 font-medium text-sm sm:text-base group-hover:text-slate-900 transition-colors">Carefully aged and processed</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-4 bg-white/60 backdrop-blur-sm rounded-xl border border-orange-200/30 hover:bg-white/80 hover:border-orange-300/50 transition-all duration-300 group">
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500 flex-shrink-0"></div>
|
||||
<span className="text-slate-700 font-medium text-sm sm:text-base group-hover:text-slate-900 transition-colors">Export quality standards</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced CTA Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 pt-6">
|
||||
<Button asChild size="lg" className="bg-gradient-to-r from-emerald-600 to-blue-600 hover:from-emerald-700 hover:to-blue-700 text-white px-6 sm:px-8 py-3 sm:py-4 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 group">
|
||||
<Link href="/kashmina-rice" className="inline-flex items-center justify-center text-sm sm:text-lg">
|
||||
Explore Kashmina Rice
|
||||
<ArrowRight className="ml-2 h-4 w-4 sm:h-5 sm:w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg" className="border-2 border-gray-300 text-gray-700 hover:bg-gray-50 hover:border-gray-400 px-6 sm:px-8 py-3 sm:py-4 rounded-xl font-semibold text-sm sm:text-lg">
|
||||
<Link href="/products?brand=kashmina">
|
||||
Shop Kashmina Products
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
236
components/sections/ManufacturingSection.tsx
Normal file
236
components/sections/ManufacturingSection.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Factory, Users, TrendingUp, Award, ArrowRight, CheckCircle, Gauge, Globe, Play } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export default function ManufacturingSection() {
|
||||
const [isVideoOpen, setIsVideoOpen] = useState(false)
|
||||
const facilities = [
|
||||
{
|
||||
icon: Factory,
|
||||
title: "Advanced Rice Milling",
|
||||
description: "Modern rice milling facility with precision technology for perfect grain separation and quality",
|
||||
value: "100+ MT/Day",
|
||||
label: "Rice Processing"
|
||||
},
|
||||
{
|
||||
icon: CheckCircle,
|
||||
title: "Rice Quality Assurance",
|
||||
description: "Multi-stage quality checks ensuring every rice grain meets export quality standards",
|
||||
value: "99.9%",
|
||||
label: "Pure Rice Quality"
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: "Basmati Export Hub",
|
||||
description: "Specialized facility for Basmati rice aging, processing and international export",
|
||||
value: "25+",
|
||||
label: "Countries Export"
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "Rice Processing Experts",
|
||||
description: "Skilled team with traditional knowledge and modern techniques in rice processing",
|
||||
value: "100+",
|
||||
label: "Rice Specialists"
|
||||
}
|
||||
]
|
||||
|
||||
const processSteps = [
|
||||
{
|
||||
step: "01",
|
||||
title: "Premium Paddy Sourcing",
|
||||
description: "Direct procurement from Punjab & Haryana's best rice growing regions",
|
||||
image: "/farmer.png"
|
||||
},
|
||||
{
|
||||
step: "02",
|
||||
title: "Traditional Rice Aging",
|
||||
description: "Natural aging process for Basmati rice to develop characteristic aroma and length",
|
||||
image: "/factory.png"
|
||||
},
|
||||
{
|
||||
step: "03",
|
||||
title: "Rice Quality Laboratory",
|
||||
description: "Comprehensive testing for grain length, aroma, moisture and purity standards",
|
||||
image: "/lab.png"
|
||||
},
|
||||
{
|
||||
step: "04",
|
||||
title: "Premium Rice Packaging",
|
||||
description: "Food-grade packaging with moisture control for long-lasting rice freshness",
|
||||
image: "/rice_bags.png"
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="relative py-16 lg:py-24 overflow-hidden">
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-amber-50 via-yellow-50 to-orange-50/30"></div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(245,158,11,0.05),transparent_50%)]"></div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_80%_70%,rgba(251,191,36,0.05),transparent_50%)]"></div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 bg-amber-100/50 backdrop-blur-sm px-4 py-2 rounded-full mb-6">
|
||||
<Factory className="w-4 h-4 text-amber-600" />
|
||||
<span className="text-amber-700 font-medium text-sm">Rice Processing Excellence</span>
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 mb-6">
|
||||
From Paddy Fields to
|
||||
<span className="text-transparent bg-gradient-to-r from-amber-600 via-yellow-500 to-orange-500 bg-clip-text"> Premium Rice</span>
|
||||
</h2>
|
||||
<p className="text-xl text-slate-600 max-w-3xl mx-auto leading-relaxed">
|
||||
State-of-the-art rice processing facilities ensuring every grain of Basmati and Sella rice meets international quality standards
|
||||
</p>
|
||||
|
||||
{/* Video Button */}
|
||||
<div className="mt-8">
|
||||
<Dialog open={isVideoOpen} onOpenChange={setIsVideoOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-700 text-white px-8 py-4 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 group"
|
||||
>
|
||||
<Play className="w-5 h-5 mr-2 group-hover:scale-110 transition-transform" />
|
||||
Watch Video
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl w-full p-0 bg-black border-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Rice Processing Video</DialogTitle>
|
||||
<DialogDescription>
|
||||
Watch our complete rice processing journey from paddy to premium rice
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="relative w-full h-0 pb-[56.25%]"> {/* 16:9 aspect ratio */}
|
||||
<video
|
||||
className="absolute inset-0 w-full h-full"
|
||||
controls
|
||||
autoPlay
|
||||
preload="metadata"
|
||||
poster="/factory.png" // You can add a poster image
|
||||
>
|
||||
<source src="https://4m5m4tx28rtva30c.public.blob.vercel-storage.com/media/2025-09-07/rice-processing" type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Facilities Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-20">
|
||||
{facilities.map((facility, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 group hover:border-amber-200"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-amber-100 to-yellow-200 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
||||
<facility.icon className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-amber-600">{facility.value}</div>
|
||||
<div className="text-xs text-slate-500 font-medium">{facility.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">{facility.title}</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{facility.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Process Flow */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="bg-white rounded-3xl shadow-2xl p-8 lg:p-12 border border-gray-100"
|
||||
>
|
||||
<div className="text-center mb-12">
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-slate-900 mb-4">
|
||||
Our Quality Process
|
||||
</h3>
|
||||
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
|
||||
Every step carefully monitored to deliver premium quality rice that exceeds expectations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{processSteps.map((step, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.2 }}
|
||||
className="relative text-center group"
|
||||
>
|
||||
{/* Step Number */}
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-emerald-600 to-emerald-700 text-white rounded-full font-bold text-lg mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
{step.step}
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="relative w-24 h-24 mx-auto mb-6 rounded-2xl overflow-hidden shadow-lg group-hover:shadow-xl transition-shadow duration-300">
|
||||
<Image
|
||||
src={step.image}
|
||||
alt={step.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h4 className="text-lg font-semibold text-slate-900 mb-3">{step.title}</h4>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{step.description}</p>
|
||||
|
||||
{/* Connector Line */}
|
||||
{index < processSteps.length - 1 && (
|
||||
<div className="hidden lg:block absolute top-6 left-full w-full h-0.5 bg-gradient-to-r from-emerald-200 to-emerald-300 transform translate-x-4"></div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="text-center mt-12">
|
||||
<Button asChild size="lg" className="bg-gradient-to-r from-emerald-600 to-emerald-700 hover:from-emerald-700 hover:to-emerald-800 text-white px-8 py-4 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 group">
|
||||
<Link href="/about" className="inline-flex items-center">
|
||||
Visit Our Facility
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
154
components/sections/NewsSection.tsx
Normal file
154
components/sections/NewsSection.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar, ArrowRight, ExternalLink, Clock, TrendingUp, Wheat, Users, Sparkles } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export default function NewsSection() {
|
||||
// Content data in JSON format for easy management
|
||||
const riceStoriesData = {
|
||||
header: {
|
||||
badge: "Padmaaja Rasooi",
|
||||
title: "Rice Stories",
|
||||
description: "For nearly 10,000 years, rice has nourished more communities than any other grain. Deeply woven into human history across continents, it remains our timeless bond with the golden grain."
|
||||
},
|
||||
stories: [
|
||||
{
|
||||
id: 1,
|
||||
category: "Culinary Heritage",
|
||||
title: "Global Favourite",
|
||||
description: "Rice inspires delicious dishes in every culture.",
|
||||
image: "https://www.indiagatefoods.com/wp-content/themes/india-gate/static/images/rice-dish-varieties.jpg",
|
||||
buttonText: "DISCOVER MORE",
|
||||
bgGradient: "from-amber-900 via-orange-800 to-red-900",
|
||||
categoryColor: "text-amber-300",
|
||||
categoryIcon: "Wheat"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: "Cultural Journey",
|
||||
title: "Where Rice Meets Heart",
|
||||
description: "Every grain connects us to family and tradition.",
|
||||
image: "https://4m5m4tx28rtva30c.public.blob.vercel-storage.com/media/2025-09-07/indian-family-dinner",
|
||||
buttonText: "EXPLORE MORE",
|
||||
bgGradient: "from-emerald-900 via-teal-800 to-blue-900",
|
||||
categoryColor: "text-emerald-300",
|
||||
categoryIcon: "Users"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category: "Traditional Farming",
|
||||
title: "Heritage Cultivation",
|
||||
description: "Ancient farming wisdom shapes every harvest.",
|
||||
image: "https://4m5m4tx28rtva30c.public.blob.vercel-storage.com/media/2025-09-07/deepak-kumar-b4eRRodrirQ-unsplash%20%281%29.webp",
|
||||
buttonText: "LEARN MORE",
|
||||
bgGradient: "from-green-900 to-emerald-800",
|
||||
categoryColor: "text-green-300",
|
||||
categoryIcon: "Wheat"
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category: "Premium Quality",
|
||||
title: "Miracle Grain",
|
||||
description: "Discover unique rice varieties and their benefits.",
|
||||
image: "https://4m5m4tx28rtva30c.public.blob.vercel-storage.com/media/2025-09-07/Image_fx%20%2840%29%20%281%29.jpg",
|
||||
buttonText: "DISCOVER MORE",
|
||||
bgGradient: "from-amber-900 to-orange-800",
|
||||
categoryColor: "text-amber-300",
|
||||
categoryIcon: "Sparkles"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const getIcon = (iconName: string) => {
|
||||
switch (iconName) {
|
||||
case 'Wheat':
|
||||
return <Wheat className="w-5 h-5" />
|
||||
case 'Users':
|
||||
return <Users className="w-5 h-5" />
|
||||
case 'Sparkles':
|
||||
return <Sparkles className="w-5 h-5" />
|
||||
default:
|
||||
return <Wheat className="w-5 h-5" />
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="relative py-16 lg:py-24 overflow-hidden bg-gradient-to-br from-amber-50/50 via-orange-50/30 to-red-50/20">
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
|
||||
<h2 className="text-3xl md:text-3xl lg:text-4xl font-bold mb-6">
|
||||
<span className="bg-gradient-to-r from-amber-700 via-red-600 to-orange-700 bg-clip-text text-transparent">
|
||||
{riceStoriesData.header.title}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-amber-500 to-red-500 mx-auto mb-6"></div>
|
||||
|
||||
<p className="text-lg md:text-xl text-slate-700 max-w-4xl mx-auto leading-relaxed">
|
||||
{riceStoriesData.header.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Main Content Grid - 4 Cards in 2 Rows */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{riceStoriesData.stories.map((story, index) => (
|
||||
<motion.div
|
||||
key={story.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="group cursor-pointer"
|
||||
>
|
||||
<div className={`relative overflow-hidden rounded-xl shadow-lg h-96 bg-gradient-to-br ${story.bgGradient}`}>
|
||||
<Image
|
||||
src={story.image}
|
||||
alt={story.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
loading="lazy"
|
||||
quality={80}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent"></div>
|
||||
|
||||
{/* Content Overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-8">
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-white mb-4 leading-tight drop-shadow-lg">
|
||||
{story.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-white/90 leading-relaxed mb-6 drop-shadow-lg">
|
||||
{story.description}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="bg-white/20 backdrop-blur-sm border-white/30 text-white hover:bg-white/30 group"
|
||||
>
|
||||
{story.buttonText}
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
125
components/sections/OurValues.tsx
Normal file
125
components/sections/OurValues.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Wheat,
|
||||
Shield,
|
||||
Award,
|
||||
Leaf
|
||||
} from 'lucide-react'
|
||||
|
||||
const values = [
|
||||
{
|
||||
title: "Rice Purity",
|
||||
description: "Every grain of Basmati and Sella rice is carefully selected and tested for maximum purity.",
|
||||
icon: Shield
|
||||
},
|
||||
{
|
||||
title: "Farm Fresh Rice",
|
||||
description: "Direct sourcing from Punjab & Haryana's finest rice farms ensures authentic quality.",
|
||||
icon: Wheat
|
||||
},
|
||||
{
|
||||
title: "Rice Excellence",
|
||||
description: "Committed to delivering the finest aged Basmati and premium Sella rice varieties.",
|
||||
icon: Award
|
||||
},
|
||||
{
|
||||
title: "Sustainable Rice Farming",
|
||||
description: "Supporting eco-friendly rice cultivation practices for healthier communities and environment.",
|
||||
icon: Leaf
|
||||
}
|
||||
]
|
||||
|
||||
export default function OurValues() {
|
||||
return (
|
||||
<section className="py-20 bg-gradient-to-br from-amber-50 to-yellow-50">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-12">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center space-y-4 mb-16"
|
||||
>
|
||||
<Badge variant="secondary" className="bg-amber-100 text-amber-800 px-3 py-1 text-sm font-medium">
|
||||
Our Rice Values
|
||||
</Badge>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-slate-900">
|
||||
The Quality of Kashhmna Premium
|
||||
</h2>
|
||||
<p className="text-slate-600 max-w-2xl mx-auto text-lg">
|
||||
Our dedication to rice excellence is built on four core principles that ensure every grain of Basmati and Sella rice meets the highest standards.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Values Grid - 4 columns on desktop */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{values.map((value, index) => {
|
||||
const IconComponent = value.icon
|
||||
return (
|
||||
<motion.div
|
||||
key={value.title}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
delay: index * 0.1
|
||||
}}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{ y: -8 }}
|
||||
className="group"
|
||||
>
|
||||
<Card className="h-full bg-white/80 backdrop-blur-sm border-0 shadow-lg hover:shadow-xl transition-all duration-300 group-hover:bg-white">
|
||||
<CardContent className="p-8 text-center">
|
||||
{/* Icon */}
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-amber-500 to-orange-600 rounded-full flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<IconComponent className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-4 group-hover:text-emerald-700 transition-colors">
|
||||
{value.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-slate-600 leading-relaxed">
|
||||
{value.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mt-16"
|
||||
>
|
||||
<p className="text-slate-600 text-lg mb-6">
|
||||
Experience the difference that our values make in every grain
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Badge variant="outline" className="text-emerald-700 border-emerald-300 px-4 py-2">
|
||||
🌾 Farm to Table
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-blue-700 border-blue-300 px-4 py-2">
|
||||
🛡️ Quality Certified
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-green-700 border-green-300 px-4 py-2">
|
||||
🌱 Eco-Friendly
|
||||
</Badge>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
298
components/sections/PageHero.tsx
Normal file
298
components/sections/PageHero.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { ChevronRight, LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
interface HeroAction {
|
||||
label: string
|
||||
icon?: LucideIcon
|
||||
onClick?: () => void
|
||||
href?: string
|
||||
variant?: 'primary' | 'secondary'
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface HeroFeature {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
color?: 'blue' | 'green' | 'purple' | 'orange' | 'emerald'
|
||||
}
|
||||
|
||||
interface PageHeroProps {
|
||||
// Basic content
|
||||
title: string
|
||||
subtitle?: string
|
||||
description: string
|
||||
|
||||
// Badge configuration
|
||||
badge?: {
|
||||
text: string
|
||||
variant?: 'default' | 'outline'
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Icon configuration
|
||||
icon?: {
|
||||
component: LucideIcon
|
||||
className?: string
|
||||
bgColor?: string
|
||||
}
|
||||
|
||||
// Breadcrumb navigation
|
||||
breadcrumbs?: BreadcrumbItem[]
|
||||
|
||||
// Features/highlights (small cards below description)
|
||||
features?: HeroFeature[]
|
||||
|
||||
// Action buttons
|
||||
actions?: HeroAction[]
|
||||
|
||||
// Styling customization
|
||||
backgroundGradient?: string
|
||||
titleGradient?: string
|
||||
className?: string
|
||||
|
||||
// Content positioning
|
||||
alignment?: 'center' | 'left' | 'auto' // auto = smart defaults based on content
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '7xl'
|
||||
}
|
||||
|
||||
const getIconColorClasses = (color: HeroFeature['color'] = 'blue') => {
|
||||
const colorMap = {
|
||||
blue: 'text-blue-500',
|
||||
green: 'text-green-500',
|
||||
purple: 'text-purple-500',
|
||||
orange: 'text-orange-500',
|
||||
emerald: 'text-emerald-500'
|
||||
}
|
||||
return colorMap[color]
|
||||
}
|
||||
|
||||
const getMaxWidthClass = (maxWidth: PageHeroProps['maxWidth'] = '5xl') => {
|
||||
const maxWidthMap = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
'3xl': 'max-w-3xl',
|
||||
'4xl': 'max-w-4xl',
|
||||
'5xl': 'max-w-5xl',
|
||||
'7xl': 'max-w-7xl'
|
||||
}
|
||||
return maxWidthMap[maxWidth]
|
||||
}
|
||||
|
||||
export default function PageHero({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
badge,
|
||||
icon,
|
||||
breadcrumbs,
|
||||
features,
|
||||
actions,
|
||||
backgroundGradient = 'from-blue-600/10 to-emerald-600/10',
|
||||
titleGradient = 'from-blue-600 to-emerald-600',
|
||||
className = '',
|
||||
alignment = 'auto',
|
||||
maxWidth = '4xl'
|
||||
}: PageHeroProps) {
|
||||
const maxWidthClass = getMaxWidthClass(maxWidth)
|
||||
|
||||
// Smart alignment logic - Modern approach (2024-2025)
|
||||
// Center: Marketing/brand pages, landing pages, promotional content
|
||||
// Left: Content-heavy pages, documentation, application interfaces
|
||||
const getAlignment = () => {
|
||||
if (alignment !== 'auto') return alignment
|
||||
|
||||
// Auto-detect best alignment based on content
|
||||
const hasActions = actions && actions.length > 0
|
||||
const hasFeatures = features && features.length > 0
|
||||
const hasIcon = icon !== undefined
|
||||
const hasBadge = badge !== undefined
|
||||
|
||||
// Marketing/promotional content -> center
|
||||
if (hasActions || hasFeatures || hasIcon || hasBadge) {
|
||||
return 'center'
|
||||
}
|
||||
|
||||
// Long description -> left (better readability)
|
||||
if (description.length > 120) {
|
||||
return 'left'
|
||||
}
|
||||
|
||||
// Default to center for brand/marketing pages
|
||||
return 'center'
|
||||
}
|
||||
|
||||
const isCenter = getAlignment() === 'center'
|
||||
|
||||
return (
|
||||
<section className="relative py-12 overflow-hidden min-h-[30vh] flex items-center">
|
||||
{/* Background Gradient - Standardized */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/10 to-emerald-600/10" />
|
||||
|
||||
{/* Breadcrumb Navigation - Standardized positioning and styling */}
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<nav className="absolute top-20 left-0 right-0 z-30">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center h-16">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-slate-300 hover:text-white transition-colors font-medium"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-white font-semibold">{item.label}</span>
|
||||
)}
|
||||
{index < breadcrumbs.length - 1 && (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Main Content - Standardized container and spacing */}
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className={isCenter ? 'text-center' : 'text-left'}
|
||||
>
|
||||
{/* Badge with Icon - Standardized styling */}
|
||||
{badge && (
|
||||
<div className={`flex ${isCenter ? 'justify-center' : 'justify-start'} mb-6`}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-blue-700 border-blue-200 bg-blue-50/50 px-4 py-2 text-sm font-medium rounded-full flex items-center space-x-2"
|
||||
>
|
||||
{icon && (
|
||||
<div className={`p-1.5 rounded-lg ${icon.bgColor || 'bg-blue-600'} ${icon.className || ''}`}>
|
||||
<icon.component className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<span>{badge.text}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon (only when no badge) */}
|
||||
{icon && !badge && (
|
||||
<div className={`flex ${isCenter ? 'justify-center' : 'justify-start'} mb-6`}>
|
||||
<div className={`p-3 rounded-2xl ${icon.bgColor || 'bg-blue-600'} ${icon.className || ''}`}>
|
||||
<icon.component className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title - Standardized typography scale */}
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-800 mb-6">
|
||||
{subtitle ? (
|
||||
<>
|
||||
{subtitle}{' '}
|
||||
<span className="bg-gradient-to-r from-blue-600 to-emerald-600 bg-clip-text text-transparent">
|
||||
{title}
|
||||
</span>
|
||||
</>
|
||||
) : title.includes(' ') ? (
|
||||
<>
|
||||
{title.split(' ').slice(0, -1).join(' ')}{' '}
|
||||
<span className="bg-gradient-to-r from-blue-600 to-emerald-600 bg-clip-text text-transparent">
|
||||
{title.split(' ').slice(-1)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="bg-gradient-to-r from-blue-600 to-emerald-600 bg-clip-text text-transparent">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
{/* Description - Standardized typography */}
|
||||
<p className={`text-lg text-slate-600 ${maxWidthClass} ${isCenter ? 'mx-auto' : ''} ${
|
||||
(features && features.length > 0) || (actions && actions.length > 0) ? 'mb-8' : 'mb-0'
|
||||
} leading-relaxed`}>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
{features && features.length > 0 && (
|
||||
<div className={`flex flex-wrap ${isCenter ? 'justify-center' : 'justify-start'} gap-4 ${
|
||||
(actions && actions.length > 0) ? 'mb-8' : 'mb-0'
|
||||
}`}>
|
||||
{features.map((feature, index) => (
|
||||
<div key={index} className="flex items-center space-x-2 bg-white rounded-full px-4 py-2 shadow-sm">
|
||||
<feature.icon className={`w-5 h-5 ${getIconColorClasses(feature.color)}`} />
|
||||
<span className="text-sm font-medium text-slate-700">{feature.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons - Standardized styling */}
|
||||
{actions && actions.length > 0 && (
|
||||
<div className={`flex flex-col sm:flex-row gap-4 ${isCenter ? 'justify-center' : 'justify-start'}`}>
|
||||
{actions.map((action, index) => {
|
||||
const ButtonContent = (
|
||||
<>
|
||||
{action.icon && <action.icon className="w-5 h-5 mr-2" />}
|
||||
{action.label}
|
||||
</>
|
||||
)
|
||||
|
||||
const buttonClass = action.variant === 'secondary'
|
||||
? "border-slate-300"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||
|
||||
if (action.href) {
|
||||
return (
|
||||
<Link key={index} href={action.href}>
|
||||
<Button
|
||||
size="lg"
|
||||
variant={action.variant === 'secondary' ? 'outline' : 'default'}
|
||||
className={`${buttonClass} ${action.className || ''}`}
|
||||
>
|
||||
{ButtonContent}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
size="lg"
|
||||
variant={action.variant === 'secondary' ? 'outline' : 'default'}
|
||||
className={`${buttonClass} ${action.className || ''}`}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{ButtonContent}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
293
components/sections/ProductsSection.tsx
Normal file
293
components/sections/ProductsSection.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
price: number
|
||||
description: string | null
|
||||
images: string[]
|
||||
category: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
interface ProductsSectionProps {
|
||||
products: Product[]
|
||||
categories: Category[]
|
||||
}
|
||||
|
||||
export default function ProductsSection({ products, categories }: ProductsSectionProps) {
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all')
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const [productsPerSlide, setProductsPerSlide] = useState(4)
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null)
|
||||
const [touchEnd, setTouchEnd] = useState<number | null>(null)
|
||||
|
||||
// Update products per slide based on window size
|
||||
useEffect(() => {
|
||||
const updateProductsPerSlide = () => {
|
||||
if (window.innerWidth < 640) {
|
||||
setProductsPerSlide(1) // mobile
|
||||
} else if (window.innerWidth < 1024) {
|
||||
setProductsPerSlide(2) // tablet
|
||||
} else {
|
||||
setProductsPerSlide(4) // desktop
|
||||
}
|
||||
}
|
||||
|
||||
updateProductsPerSlide()
|
||||
window.addEventListener('resize', updateProductsPerSlide)
|
||||
return () => window.removeEventListener('resize', updateProductsPerSlide)
|
||||
}, [])
|
||||
|
||||
// Create category display names mapping
|
||||
const getCategoryDisplayName = (category: Category) => {
|
||||
const displayNames: { [key: string]: string } = {
|
||||
'basmati': 'Perfectionist',
|
||||
'premium': 'Quality Seeker',
|
||||
'organic': 'Taste Champion',
|
||||
'specialty': 'Smart Shopper',
|
||||
'kashmina steam': 'Kashmina Steam',
|
||||
'kashmina': 'Kashmina Steam',
|
||||
'steam': 'Kashmina Steam',
|
||||
'rice': 'Premium Rice'
|
||||
}
|
||||
return displayNames[category.name.toLowerCase()] || category.name
|
||||
}
|
||||
|
||||
// Add "All" category with enhanced categories
|
||||
const allCategories = [
|
||||
{ id: 'all', name: 'All', displayName: 'All' },
|
||||
...categories.map(cat => ({
|
||||
...cat,
|
||||
displayName: getCategoryDisplayName(cat)
|
||||
}))
|
||||
]
|
||||
|
||||
// Filter products based on active category
|
||||
const filteredProducts = activeCategory === 'all'
|
||||
? products
|
||||
: products.filter(product => product.category.id === activeCategory)
|
||||
|
||||
// Products per slide (responsive)
|
||||
const getProductsPerSlide = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (window.innerWidth < 640) return 1 // mobile
|
||||
if (window.innerWidth < 1024) return 2 // tablet
|
||||
return 4 // desktop
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
const totalSlides = Math.ceil(filteredProducts.length / productsPerSlide)
|
||||
|
||||
const nextSlide = () => {
|
||||
setCurrentSlide((prev) => (prev + 1) % totalSlides)
|
||||
}
|
||||
|
||||
const prevSlide = () => {
|
||||
setCurrentSlide((prev) => (prev - 1 + totalSlides) % totalSlides)
|
||||
}
|
||||
|
||||
const handleCategoryChange = (categoryId: string) => {
|
||||
setActiveCategory(categoryId)
|
||||
setCurrentSlide(0) // Reset to first slide when category changes
|
||||
}
|
||||
|
||||
// Touch handlers for mobile swipe
|
||||
const minSwipeDistance = 50
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
setTouchEnd(null) // otherwise the swipe is fired even with usual touch events
|
||||
setTouchStart(e.targetTouches[0].clientX)
|
||||
}
|
||||
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
setTouchEnd(e.targetTouches[0].clientX)
|
||||
}
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (!touchStart || !touchEnd) return
|
||||
const distance = touchStart - touchEnd
|
||||
const isLeftSwipe = distance > minSwipeDistance
|
||||
const isRightSwipe = distance < -minSwipeDistance
|
||||
|
||||
if (isLeftSwipe && currentSlide < totalSlides - 1) {
|
||||
nextSlide()
|
||||
} else if (isRightSwipe && currentSlide > 0) {
|
||||
prevSlide()
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentProducts = () => {
|
||||
const startIndex = currentSlide * productsPerSlide
|
||||
return filteredProducts.slice(startIndex, startIndex + productsPerSlide)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-gradient-to-b from-emerald-50 to-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold text-emerald-900 mb-4">
|
||||
BASMATI RICE
|
||||
</h2>
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="w-32 h-px bg-emerald-600 relative">
|
||||
<div className="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="w-3 h-3 bg-emerald-600 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<div className="flex flex-wrap justify-center gap-4 mb-12">
|
||||
{allCategories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => handleCategoryChange(category.id)}
|
||||
className={`px-6 py-3 rounded-full border-2 transition-all duration-300 font-medium ${
|
||||
activeCategory === category.id
|
||||
? 'bg-emerald-600 text-white border-emerald-600'
|
||||
: 'bg-white text-emerald-700 border-emerald-600 hover:bg-emerald-50'
|
||||
}`}
|
||||
>
|
||||
{category.displayName || category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Products Carousel */}
|
||||
<div className="relative">
|
||||
{/* Navigation Arrows */}
|
||||
{totalSlides > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prevSlide}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 z-10 bg-white/80 hover:bg-white rounded-full p-3 shadow-lg transition-all duration-300"
|
||||
aria-label="Previous products"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-emerald-700" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextSlide}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 z-10 bg-white/80 hover:bg-white rounded-full p-3 shadow-lg transition-all duration-300"
|
||||
aria-label="Next products"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-emerald-700" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Products Grid */}
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
key={`${activeCategory}-${currentSlide}`}
|
||||
>
|
||||
{getCurrentProducts().map((product) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
className="text-center group"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Product Image */}
|
||||
<div className="relative aspect-[3/4] overflow-hidden rounded-lg mb-4">
|
||||
{product.images && product.images.length > 0 ? (
|
||||
<Image
|
||||
src={product.images[0]}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-emerald-100 to-emerald-200 flex items-center justify-center rounded-lg">
|
||||
<div className="text-emerald-600 text-4xl font-bold">
|
||||
{product.name.charAt(0)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xl font-semibold text-gray-800 group-hover:text-emerald-600 transition-colors">
|
||||
{product.name}
|
||||
</h3>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="text-emerald-600 border-emerald-600 hover:bg-emerald-50 hover:text-emerald-700 rounded-full px-6 py-2 font-medium"
|
||||
>
|
||||
<Link href={`/products/${product.slug}`}>
|
||||
KNOW MORE →
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Slide Indicators */}
|
||||
{totalSlides > 1 && (
|
||||
<div className="hidden sm:flex justify-center mt-8 space-x-1.5">
|
||||
{Array.from({ length: totalSlides }).map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentSlide(index)}
|
||||
className={`w-1.5 h-1.5 sm:w-2 sm:h-2 md:w-2.5 md:h-2.5 rounded-full transition-all duration-300 ${
|
||||
currentSlide === index ? 'bg-emerald-600' : 'bg-emerald-200'
|
||||
}`}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View All Products Button */}
|
||||
<div className="text-center mt-12">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white px-8 py-3 rounded-full text-lg font-medium"
|
||||
>
|
||||
<Link href="/products">
|
||||
View All Products
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
188
components/sections/StatsSection.tsx
Normal file
188
components/sections/StatsSection.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { TrendingUp, Globe, Users, Award, Factory, Wheat, Star, Target } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export default function StatsSection() {
|
||||
const stats = [
|
||||
{
|
||||
icon: Globe,
|
||||
value: "25+",
|
||||
label: "Countries Served",
|
||||
description: "Exporting premium Basmati rice across continents",
|
||||
color: "from-amber-500 to-yellow-600"
|
||||
},
|
||||
{
|
||||
icon: Wheat,
|
||||
value: "200+",
|
||||
label: "MT Rice Daily",
|
||||
description: "Processing capacity for premium rice varieties",
|
||||
color: "from-yellow-500 to-orange-600"
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
value: "5000+",
|
||||
label: "Rice Farmers",
|
||||
description: "Direct partnerships across Punjab & Haryana rice belt",
|
||||
color: "from-emerald-500 to-green-600"
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
value: "20+",
|
||||
label: "Years in Rice Trade",
|
||||
description: "Two decades of rice expertise and quality",
|
||||
color: "from-orange-500 to-red-600"
|
||||
},
|
||||
{
|
||||
icon: Factory,
|
||||
value: "99.9%",
|
||||
label: "Pure Rice Quality",
|
||||
description: "Consistent premium rice standards",
|
||||
color: "from-red-500 to-pink-600"
|
||||
},
|
||||
{
|
||||
icon: Star,
|
||||
value: "10K+",
|
||||
label: "Rice Loving Families",
|
||||
description: "Households enjoying our premium rice daily",
|
||||
color: "from-yellow-500 to-amber-600"
|
||||
}
|
||||
]
|
||||
|
||||
const achievements = [
|
||||
{
|
||||
title: "Basmati Rice Leadership",
|
||||
description: "Leading exporter of aged Basmati 1121 rice from Northern India",
|
||||
icon: Target
|
||||
},
|
||||
{
|
||||
title: "Rice Quality Certifications",
|
||||
description: "ISO, HACCP, FSSAI certified processing facility",
|
||||
icon: Award
|
||||
},
|
||||
{
|
||||
title: "Export Excellence",
|
||||
description: "Consistent export quality meeting international standards",
|
||||
icon: Globe
|
||||
},
|
||||
{
|
||||
title: "Innovation Pioneer",
|
||||
description: "Advanced processing technology for superior quality",
|
||||
icon: TrendingUp
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="relative py-16 lg:py-24 overflow-hidden">
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-900 via-slate-800 to-gray-900"></div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(245,158,11,0.1),transparent_50%)]"></div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_80%_70%,rgba(251,191,36,0.1),transparent_50%)]"></div>
|
||||
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute top-20 left-20 w-32 h-32 bg-amber-500/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-20 right-20 w-40 h-40 bg-yellow-500/10 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
{/* Section Header */}
|
||||
{/* <motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-6">
|
||||
<TrendingUp className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-amber-300 font-medium text-sm">Rice Excellence</span>
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||
From Fields to
|
||||
<span className="text-transparent bg-gradient-to-r from-amber-400 via-yellow-400 to-orange-400 bg-clip-text"> Global Tables</span>
|
||||
</h2>
|
||||
<p className="text-xl text-gray-300 max-w-3xl mx-auto leading-relaxed">
|
||||
Two decades of rice expertise, bringing premium Basmati and Sella varieties from Northern India's fertile plains to kitchens worldwide
|
||||
</p>
|
||||
</motion.div> */}
|
||||
|
||||
{/* Stats Grid */}
|
||||
{/* <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-20">
|
||||
{stats.map((stat, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="relative group"
|
||||
>
|
||||
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-8 border border-white/10 hover:border-white/20 transition-all duration-500 group-hover:bg-white/10">
|
||||
<div className={`absolute inset-0 rounded-2xl bg-gradient-to-br ${stat.color} opacity-0 group-hover:opacity-5 transition-opacity duration-500`}></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className={`w-16 h-16 bg-gradient-to-br ${stat.color} rounded-2xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300`}>
|
||||
<stat.icon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
|
||||
<div className="text-4xl md:text-5xl font-bold text-white mb-2 group-hover:scale-105 transition-transform duration-300">
|
||||
{stat.value}
|
||||
</div>
|
||||
|
||||
<div className="text-lg font-semibold text-gray-300 mb-3">
|
||||
{stat.label}
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 text-sm leading-relaxed">
|
||||
{stat.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div> */}
|
||||
|
||||
{/* Achievements Section */}
|
||||
{/* <motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="bg-white/5 backdrop-blur-sm rounded-3xl p-8 lg:p-12 border border-white/10"
|
||||
>
|
||||
<div className="text-center mb-12">
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-white mb-4">
|
||||
Key Achievements
|
||||
</h3>
|
||||
<p className="text-lg text-gray-300 max-w-2xl mx-auto">
|
||||
Milestones that define our commitment to excellence and industry leadership
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{achievements.map((achievement, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="text-center group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<achievement.icon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h4 className="text-lg font-semibold text-white mb-3">
|
||||
{achievement.title}
|
||||
</h4>
|
||||
<p className="text-gray-400 text-sm leading-relaxed">
|
||||
{achievement.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div> */}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
245
components/sections/SustainabilitySection.tsx
Normal file
245
components/sections/SustainabilitySection.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Leaf, Heart, Droplets, Recycle, Users, Globe, ArrowRight, TreePine } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export default function SustainabilitySection() {
|
||||
const initiatives = [
|
||||
{
|
||||
icon: Leaf,
|
||||
title: "Organic Farming Support",
|
||||
description: "Supporting farmers in transitioning to organic farming practices for sustainable agriculture",
|
||||
impact: "500+ Farmers",
|
||||
color: "from-green-500 to-emerald-500"
|
||||
},
|
||||
{
|
||||
icon: Droplets,
|
||||
title: "Water Conservation",
|
||||
description: "Implementing water-efficient processing techniques and rainwater harvesting systems",
|
||||
impact: "30% Water Saved",
|
||||
color: "from-blue-500 to-cyan-500"
|
||||
},
|
||||
{
|
||||
icon: Recycle,
|
||||
title: "Zero Waste Processing",
|
||||
description: "Converting rice husk and by-products into renewable energy and organic fertilizers",
|
||||
impact: "95% Waste Recycled",
|
||||
color: "from-purple-500 to-violet-500"
|
||||
},
|
||||
{
|
||||
icon: TreePine,
|
||||
title: "Carbon Neutral Goals",
|
||||
description: "Working towards carbon-neutral operations through renewable energy adoption",
|
||||
impact: "50% Renewable Energy",
|
||||
color: "from-emerald-500 to-green-500"
|
||||
}
|
||||
]
|
||||
|
||||
const csrPrograms = [
|
||||
{
|
||||
title: "Farmer Welfare Program",
|
||||
description: "Direct support to rice farmers through fair pricing, agricultural training, and modern farming techniques",
|
||||
image: "/farmer.png",
|
||||
beneficiaries: "5000+ Farmers",
|
||||
focus: "Education & Support"
|
||||
},
|
||||
{
|
||||
title: "Community Development",
|
||||
description: "Healthcare, education and infrastructure development in rural farming communities",
|
||||
image: "https://images.unsplash.com/photo-1559027615-cd4628902d4a?w=600&h=400&fit=crop&q=80",
|
||||
beneficiaries: "50+ Villages",
|
||||
focus: "Rural Development"
|
||||
},
|
||||
{
|
||||
title: "Food Security Initiative",
|
||||
description: "Ensuring food security through grain donation programs and nutritional awareness campaigns",
|
||||
image: "https://images.unsplash.com/photo-1593113598332-cd288d649433?w=600&h=400&fit=crop&q=80",
|
||||
beneficiaries: "10,000+ Meals",
|
||||
focus: "Food Security"
|
||||
}
|
||||
]
|
||||
|
||||
const certifications = [
|
||||
{ name: "ISO 14001", desc: "Environmental Management" },
|
||||
{ name: "FSSAI", desc: "Food Safety Standards" },
|
||||
{ name: "HACCP", desc: "Hazard Analysis" },
|
||||
{ name: "Organic", desc: "Certified Organic" }
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="relative py-16 lg:py-24 overflow-hidden">
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-green-50 via-emerald-50 to-white"></div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(34,197,94,0.1),transparent_50%)]"></div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_80%_70%,rgba(16,185,129,0.1),transparent_50%)]"></div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
{/* Environmental Initiatives */}
|
||||
<div className="mb-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{initiatives.map((initiative, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="group"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 p-6 border border-gray-100 group-hover:border-green-200 h-full">
|
||||
{/* Icon */}
|
||||
<div className={`w-16 h-16 bg-gradient-to-br ${initiative.color} rounded-2xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300`}>
|
||||
<initiative.icon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-3">{initiative.title}</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed mb-4">{initiative.description}</p>
|
||||
|
||||
{/* Impact */}
|
||||
<div className="inline-block bg-green-100 text-green-700 px-3 py-1 rounded-full text-xs font-semibold">
|
||||
{initiative.impact}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSR Programs */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="mb-20"
|
||||
>
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center gap-2 bg-blue-100/50 backdrop-blur-sm px-4 py-2 rounded-full mb-6">
|
||||
<Heart className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-blue-700 font-medium text-sm">Social Impact</span>
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-slate-900 mb-4">
|
||||
Community Development Programs
|
||||
</h3>
|
||||
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
|
||||
Creating positive impact in farming communities through targeted social initiatives
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{csrPrograms.map((program, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.2 }}
|
||||
className="group"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 overflow-hidden border border-gray-100 group-hover:border-blue-200">
|
||||
{/* Image */}
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<Image
|
||||
src={program.image}
|
||||
alt={program.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent"></div>
|
||||
|
||||
{/* Impact Badge */}
|
||||
<div className="absolute top-4 right-4 bg-white/90 backdrop-blur-sm text-slate-700 px-3 py-1 rounded-full text-xs font-semibold">
|
||||
{program.beneficiaries}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full font-medium">
|
||||
{program.focus}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="text-lg font-semibold text-slate-900 mb-3">{program.title}</h4>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{program.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Certifications & Commitments */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="bg-white rounded-3xl shadow-2xl p-8 lg:p-12 border border-gray-100"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
{/* Left Content */}
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 bg-emerald-100/50 backdrop-blur-sm px-4 py-2 rounded-full mb-6">
|
||||
<Globe className="w-4 h-4 text-emerald-600" />
|
||||
<span className="text-emerald-700 font-medium text-sm">Global Standards</span>
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-slate-900 mb-6">
|
||||
Certified for Excellence
|
||||
</h3>
|
||||
<p className="text-lg text-slate-600 leading-relaxed mb-8">
|
||||
Our commitment to quality and sustainability is validated by international certifications and industry recognition.
|
||||
</p>
|
||||
|
||||
{/* Certifications Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||
{certifications.map((cert, index) => (
|
||||
<div key={index} className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-lg font-bold text-emerald-600 mb-1">{cert.name}</div>
|
||||
<div className="text-xs text-slate-600">{cert.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button asChild size="lg" className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white px-8 py-4 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 group">
|
||||
<Link href="/about/certifications" className="inline-flex items-center">
|
||||
View All Certifications
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right Visual */}
|
||||
<div className="relative">
|
||||
<div className="relative w-full h-80 rounded-2xl overflow-hidden shadow-2xl">
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1560493676-04071c5f467b?w=600&h=400&fit=crop&q=80"
|
||||
alt="Sustainable farming"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-green-900/40 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
{/* Floating Stats */}
|
||||
<div className="absolute -bottom-6 -left-6 bg-white rounded-2xl shadow-xl p-6 border border-gray-100">
|
||||
<div className="text-2xl font-bold text-green-600">Carbon</div>
|
||||
<div className="text-sm text-slate-600">Neutral by 2030</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute -top-6 -right-6 bg-white rounded-2xl shadow-xl p-6 border border-gray-100">
|
||||
<div className="text-2xl font-bold text-blue-600">Zero</div>
|
||||
<div className="text-sm text-slate-600">Waste Goal</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
62
components/sections/cta.tsx
Normal file
62
components/sections/cta.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Mail, Phone } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
|
||||
|
||||
export default function CTA() {
|
||||
|
||||
return (
|
||||
<section className="py-12 sm:py-16 md:py-20 bg-gradient-to-r from-slate-900 to-emerald-900 text-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-black/20"></div>
|
||||
<div className="relative z-10 max-w-6xl mx-auto px-3 sm:px-6 md:px-8 lg:px-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center"
|
||||
>
|
||||
<Badge className="mb-4 sm:mb-6 bg-emerald-600 text-white px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium">Get In Touch</Badge>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-6 sm:mb-8 leading-tight">
|
||||
Ready to Partner With Us?
|
||||
</h2>
|
||||
<p className="text-base sm:text-lg text-emerald-100 mb-8 sm:mb-12 max-w-3xl mx-auto leading-relaxed px-2">
|
||||
Connect with us for bulk orders, wholesale pricing, and custom solutions.
|
||||
Let's build a healthier future together.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6 mb-8 sm:mb-12 max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-center p-4 sm:p-6 bg-white/10 rounded-lg sm:rounded-xl backdrop-blur-sm hover:bg-white/15 transition-all duration-300">
|
||||
<Phone className="h-5 w-5 sm:h-6 sm:w-6 mr-3 sm:mr-4 text-emerald-300 flex-shrink-0" />
|
||||
<div className="text-left">
|
||||
<div className="text-xs sm:text-sm text-emerald-200 font-medium">Call Us</div>
|
||||
<div className="text-base sm:text-lg font-bold">+91 94757 58817</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center p-4 sm:p-6 bg-white/10 rounded-lg sm:rounded-xl backdrop-blur-sm hover:bg-white/15 transition-all duration-300">
|
||||
<Mail className="h-5 w-5 sm:h-6 sm:w-6 mr-3 sm:mr-4 text-emerald-300 flex-shrink-0" />
|
||||
<div className="text-left">
|
||||
<div className="text-xs sm:text-sm text-emerald-200 font-medium">Email Us</div>
|
||||
<div className="text-base sm:text-lg font-bold break-all sm:break-normal">info@padmajarice.com</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center items-center max-w-md sm:max-w-none mx-auto">
|
||||
<Button size="lg" className="w-full sm:w-auto bg-white text-slate-900 hover:bg-emerald-50 px-6 sm:px-8 py-2.5 sm:py-3 text-base sm:text-lg font-bold rounded-lg sm:rounded-xl shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<Link href="/contact">Get in Touch</Link>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" className="w-full sm:w-auto border-white bg-transparent hover:bg-white hover:text-slate-900 px-6 sm:px-8 py-2.5 sm:py-3 text-base sm:text-lg font-bold rounded-lg sm:rounded-xl shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<Link href="/part-time">Part time Job</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
502
components/shop/B2BInquiryForm.tsx
Normal file
502
components/shop/B2BInquiryForm.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import * as z from 'zod'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2, Package, Building, Phone, Mail, MessageCircle } from 'lucide-react'
|
||||
|
||||
const inquirySchema = z.object({
|
||||
// Company Information
|
||||
companyName: z.string().min(2, 'Company name must be at least 2 characters'),
|
||||
contactPerson: z.string().min(2, 'Contact person name must be at least 2 characters'),
|
||||
designation: z.string().min(2, 'Designation is required'),
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
phone: z.string().min(10, 'Please enter a valid phone number'),
|
||||
|
||||
// Business Details
|
||||
businessType: z.string().min(1, 'Please select business type'),
|
||||
gstNumber: z.string().optional(),
|
||||
address: z.string().min(10, 'Please provide complete address'),
|
||||
|
||||
// Product Requirements
|
||||
quantityRequired: z.string().min(1, 'Quantity is required'),
|
||||
quantityUnit: z.string().default('tons'),
|
||||
deliveryLocation: z.string().min(2, 'Delivery location is required'),
|
||||
expectedDeliveryDate: z.string().optional(),
|
||||
|
||||
// Additional Information
|
||||
message: z.string().min(10, 'Please provide detailed requirements (minimum 10 characters)'),
|
||||
hearAboutUs: z.string().optional(),
|
||||
|
||||
// Terms
|
||||
agreedToTerms: z.boolean().refine(val => val === true, {
|
||||
message: 'You must agree to the terms and conditions'
|
||||
})
|
||||
})
|
||||
|
||||
type InquiryFormData = z.infer<typeof inquirySchema>
|
||||
|
||||
interface B2BInquiryFormProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
product?: {
|
||||
id: string
|
||||
name: string
|
||||
category: { name: string }
|
||||
price: number
|
||||
weight?: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function B2BInquiryForm({ isOpen, onOpenChange, product }: B2BInquiryFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const form = useForm<InquiryFormData>({
|
||||
resolver: zodResolver(inquirySchema),
|
||||
defaultValues: {
|
||||
companyName: '',
|
||||
contactPerson: '',
|
||||
designation: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
businessType: '',
|
||||
gstNumber: '',
|
||||
address: '',
|
||||
quantityRequired: '',
|
||||
quantityUnit: 'tons',
|
||||
deliveryLocation: '',
|
||||
expectedDeliveryDate: '',
|
||||
message: product ? `I am interested in bulk procurement of ${product.name}. Please provide detailed quotation including pricing, minimum order quantity, and delivery terms.` : '',
|
||||
hearAboutUs: '',
|
||||
agreedToTerms: false
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = async (data: InquiryFormData) => {
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const formData = {
|
||||
...data,
|
||||
productId: product?.id,
|
||||
productName: product?.name,
|
||||
productCategory: product?.category?.name,
|
||||
productPrice: product?.price,
|
||||
submissionType: 'b2b_inquiry',
|
||||
submittedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
const response = await fetch('/api/inquiries', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to submit inquiry')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
toast.success('Inquiry submitted successfully!', {
|
||||
description: 'Our team will contact you within 24 hours with a detailed quotation.'
|
||||
})
|
||||
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error submitting inquiry:', error)
|
||||
toast.error('Failed to submit inquiry', {
|
||||
description: 'Please try again or contact us directly.'
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<Package className="h-6 w-6 text-emerald-600" />
|
||||
B2B Inquiry Form
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{product ? (
|
||||
<>Requesting quote for: <span className="font-semibold text-emerald-600">{product.name}</span></>
|
||||
) : (
|
||||
'Fill out this form to get a detailed quotation for bulk orders'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Company Information Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2 text-slate-700 border-b pb-2">
|
||||
<Building className="h-5 w-5" />
|
||||
Company Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="companyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Company Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter your company name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="businessType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Business Type *</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select business type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="retailer">Retailer</SelectItem>
|
||||
<SelectItem value="wholesaler">Wholesaler</SelectItem>
|
||||
<SelectItem value="distributor">Distributor</SelectItem>
|
||||
<SelectItem value="restaurant">Restaurant/Hotel</SelectItem>
|
||||
<SelectItem value="caterer">Catering Service</SelectItem>
|
||||
<SelectItem value="food_processor">Food Processor</SelectItem>
|
||||
<SelectItem value="export">Export Business</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contactPerson"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contact Person *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Full name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="designation"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Designation *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., Procurement Manager" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
<Mail className="h-4 w-4" />
|
||||
Email Address *
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="business@company.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
<Phone className="h-4 w-4" />
|
||||
Phone Number *
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="tel" placeholder="+91 98765 43210" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gstNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>GST Number (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter GST number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="address"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Complete Business Address *</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter complete address including city, state, and pincode"
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Requirements Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2 text-slate-700 border-b pb-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Product Requirements
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="quantityRequired"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Quantity Required *</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="e.g., 10" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="quantityUnit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Unit</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="tons">Tons</SelectItem>
|
||||
<SelectItem value="kg">Kilograms</SelectItem>
|
||||
<SelectItem value="quintal">Quintal</SelectItem>
|
||||
<SelectItem value="bags">Bags</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expectedDeliveryDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Expected Delivery Date</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="deliveryLocation"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delivery Location *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="City, State where delivery is required" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional Information Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2 text-slate-700 border-b pb-2">
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
Additional Information
|
||||
</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Detailed Requirements *</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Please provide specific requirements including quality specifications, packaging preferences, payment terms, or any other details that would help us provide an accurate quotation."
|
||||
className="min-h-[120px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hearAboutUs"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>How did you hear about us? (Optional)</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="google_search">Google Search</SelectItem>
|
||||
<SelectItem value="social_media">Social Media</SelectItem>
|
||||
<SelectItem value="referral">Referral</SelectItem>
|
||||
<SelectItem value="trade_show">Trade Show</SelectItem>
|
||||
<SelectItem value="advertisement">Advertisement</SelectItem>
|
||||
<SelectItem value="existing_customer">Existing Customer</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Terms and Conditions */}
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agreedToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm">
|
||||
I agree to the terms and conditions and authorize Padmaaja Rasooi to contact me regarding this inquiry. *
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex gap-4 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit Inquiry'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
195
components/shop/CartSidebar.tsx
Normal file
195
components/shop/CartSidebar.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ShoppingCart, X, Plus, Minus } from 'lucide-react'
|
||||
import { cartManager } from '@/lib/cart'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface CartItem {
|
||||
id: string
|
||||
name: string
|
||||
price: number
|
||||
quantity: number
|
||||
image: string | null
|
||||
}
|
||||
|
||||
interface CartSidebarProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function CartSidebar({ children }: CartSidebarProps) {
|
||||
const [cart, setCart] = useState<CartItem[]>([])
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const loadCart = () => {
|
||||
const cartItems = cartManager.getCart()
|
||||
setCart(cartItems)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadCart()
|
||||
|
||||
const handleCartUpdate = () => loadCart()
|
||||
window.addEventListener('cartUpdated', handleCartUpdate)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('cartUpdated', handleCartUpdate)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateQuantity = (productId: string, newQuantity: number) => {
|
||||
cartManager.updateQuantity(productId, newQuantity)
|
||||
loadCart()
|
||||
}
|
||||
|
||||
const removeItem = (productId: string) => {
|
||||
cartManager.removeFromCart(productId)
|
||||
toast.success('Item removed from cart')
|
||||
loadCart()
|
||||
}
|
||||
|
||||
const itemCount = cart.reduce((sum, item) => sum + item.quantity, 0)
|
||||
const cartTotal = cartManager.getTotalPrice()
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetTrigger asChild>
|
||||
{children || (
|
||||
<Button variant="ghost" size="sm" className="relative">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
{itemCount > 0 && (
|
||||
<Badge
|
||||
className="absolute -top-[5px] -right-[5px] h-5 w-5 flex items-center justify-center p-0 bg-red-500 text-white !text-xs"
|
||||
>
|
||||
{itemCount > 99 ? '99+' : itemCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent className="w-full sm:max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center">
|
||||
<ShoppingCart className="h-5 w-5 mr-2" />
|
||||
Shopping Cart ({itemCount})
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Manage your cart items
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 flex-1 overflow-y-auto">
|
||||
{cart.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<ShoppingCart className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">Your cart is empty</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => setIsOpen(false)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/products">Continue Shopping</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{cart.map((item) => (
|
||||
<div key={item.id} className="flex items-start space-x-3 p-3 border rounded-lg">
|
||||
<Image
|
||||
src={item.image || 'https://images.pexels.com/photos/3683107/pexels-photo-3683107.jpeg'}
|
||||
alt={item.name}
|
||||
width={60}
|
||||
height={60}
|
||||
className="rounded-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm line-clamp-2">{item.name}</h4>
|
||||
|
||||
<div className="flex items-center space-x-1 mt-1">
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
₹{(item.price || 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
||||
disabled={item.quantity <= 1}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<span className="text-sm font-medium w-8 text-center">
|
||||
{item.quantity || 0}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
onClick={() => removeItem(item.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex justify-between font-bold">
|
||||
<span>Total</span>
|
||||
<span>₹{cartTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setIsOpen(false)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/checkout">Checkout</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setIsOpen(false)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/cart">View Cart</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
463
components/shop/ProductCard.tsx
Normal file
463
components/shop/ProductCard.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ShoppingCart, Star, Eye, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import OptimizedImage from '@/components/ui/OptimizedImage'
|
||||
import { motion } from 'framer-motion'
|
||||
import { cartManager } from '@/lib/cart'
|
||||
import { toast } from 'sonner'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Product } from '@/types'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { isFeatureEnabled } from '@/lib/business-config'
|
||||
import B2BInquiryForm from '@/components/shop/B2BInquiryForm'
|
||||
|
||||
interface ProductReviewStats {
|
||||
averageRating: number
|
||||
totalReviews: number
|
||||
}
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product
|
||||
index: number
|
||||
}
|
||||
|
||||
export default function ProductCard({ product, index }: ProductCardProps) {
|
||||
const router = useRouter()
|
||||
const [isQuickViewOpen, setIsQuickViewOpen] = useState(false)
|
||||
const [isInquiryFormOpen, setIsInquiryFormOpen] = useState(false)
|
||||
const [reviewStats, setReviewStats] = useState<ProductReviewStats>({
|
||||
averageRating: 0,
|
||||
totalReviews: 0
|
||||
})
|
||||
|
||||
// Fetch review statistics for the product
|
||||
useEffect(() => {
|
||||
const fetchReviewStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/reviews?productId=${product.id}&limit=1000`)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.reviews) {
|
||||
const reviews = data.reviews
|
||||
const totalReviews = reviews.length
|
||||
|
||||
if (totalReviews > 0) {
|
||||
const sum = reviews.reduce((acc: number, review: any) => acc + review.rating, 0)
|
||||
const averageRating = sum / totalReviews
|
||||
|
||||
setReviewStats({
|
||||
averageRating: Math.round(averageRating * 10) / 10, // Round to 1 decimal
|
||||
totalReviews
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch review stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchReviewStats()
|
||||
}, [product.id])
|
||||
|
||||
const getDiscountedPrice = (price: number, discount: number) => {
|
||||
return price - (price * discount / 100)
|
||||
}
|
||||
|
||||
// Helper function to calculate per kg price
|
||||
const getPerKgPrice = (price: number, weight: string | null, discount: number = 0) => {
|
||||
if (!weight) return null
|
||||
|
||||
// Extract numeric value from weight string (e.g., "1kg", "500g", "2.5 kg")
|
||||
const weightMatch = weight.toLowerCase().match(/(\d+(?:\.\d+)?)\s*(kg|g|gram|kilos?)/i)
|
||||
if (!weightMatch) return null
|
||||
|
||||
const value = parseFloat(weightMatch[1])
|
||||
const unit = weightMatch[2].toLowerCase()
|
||||
|
||||
// Convert to kg
|
||||
let weightInKg = value
|
||||
if (unit.startsWith('g')) {
|
||||
weightInKg = value / 1000
|
||||
}
|
||||
|
||||
const finalPrice = discount > 0 ? getDiscountedPrice(price, discount) : price
|
||||
return Math.round(finalPrice / weightInKg)
|
||||
}
|
||||
|
||||
const handleAddToCart = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
console.log('Add to cart clicked for:', product.name) // Debug log
|
||||
|
||||
if (product.stock === 0) {
|
||||
toast.error('Product is out of stock')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const success = cartManager.addToCart(product, 1)
|
||||
if (success) {
|
||||
console.log('Item added successfully') // Debug log
|
||||
toast.success(`${product.name} added to cart!`)
|
||||
} else {
|
||||
toast.error('Not enough stock available')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding to cart:', error)
|
||||
toast.error('Failed to add to cart')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardClick = () => {
|
||||
router.push(`/products/${product.slug}`)
|
||||
}
|
||||
|
||||
const handleViewDetails = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setIsQuickViewOpen(true)
|
||||
}
|
||||
|
||||
const handleRequestQuote = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsInquiryFormOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Card
|
||||
className="h-full hover:shadow-lg transition-all duration-300 group overflow-hidden cursor-pointer hover:scale-[1.02]"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<OptimizedImage
|
||||
src={product.images[0] || 'https://images.pexels.com/photos/3683107/pexels-photo-3683107.jpeg'}
|
||||
alt={product.name}
|
||||
width={400}
|
||||
height={300}
|
||||
className="w-full h-full object-contain group-hover:scale-105 transition-transform duration-300"
|
||||
priority={index < 4} // Prioritize first 4 products
|
||||
quality={50}
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 25vw"
|
||||
/>
|
||||
|
||||
{/* Per kg price badge - Top Left */}
|
||||
{getPerKgPrice(product.price, product.weight, product.discount) && (
|
||||
<Badge className="absolute top-2 left-2 sm:top-3 sm:left-3 bg-emerald-500 hover:bg-emerald-600 text-white font-semibold shadow-lg text-base">
|
||||
₹{getPerKgPrice(product.price, product.weight, product.discount)}/kg
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Discount badge - Top Left, below per kg badge if both exist */}
|
||||
{product.discount > 0 && (
|
||||
<Badge className={`absolute ${getPerKgPrice(product.price, product.weight, product.discount) ? 'top-8 sm:top-10' : 'top-2 sm:top-3'} left-2 sm:left-3 bg-red-500 hover:bg-red-600 text-white font-semibold shadow-lg text-xs`}>
|
||||
{product.discount}% OFF
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Dynamic Rating Display */}
|
||||
{reviewStats.totalReviews > 0 ? (
|
||||
<div className="absolute top-2 right-2 sm:top-3 sm:right-3 flex items-center bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 shadow-lg">
|
||||
<Star className="h-3 w-3 text-yellow-400 fill-current" />
|
||||
<span className="text-xs ml-1 font-medium">{reviewStats.averageRating}</span>
|
||||
<span className="text-xs ml-1 text-gray-500">({reviewStats.totalReviews})</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
</>
|
||||
)}
|
||||
{product.stock === 0 && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<Badge variant="destructive" className="text-xs sm:text-sm">Out of Stock</Badge>
|
||||
</div>
|
||||
)}
|
||||
<Badge className="absolute bottom-2 right-2 sm:bottom-3 sm:right-3 bg-white/90 text-slate-700 border border-slate-200 text-xs font-medium shadow-lg backdrop-blur-sm">
|
||||
{product.category.name}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<CardHeader className="pb-2 p-3 sm:pb-2">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardTitle className="text-base sm:text-lg font-semibold truncate group-hover:text-blue-600 transition-colors flex-1 leading-tight cursor-help">
|
||||
{product.name}
|
||||
</CardTitle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>{product.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2 md:text-xs text-sm text-gray-600 leading-relaxed">
|
||||
{product.description || 'Premium quality product from Padmaaja Rasooi Pvt. Ltd.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0 p-3 sm:p-6 sm:pt-0 space-y-3 sm:space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{product.discount > 0 ? (
|
||||
<>
|
||||
<span className="text-base sm:text-lg font-bold text-green-600">
|
||||
₹{getDiscountedPrice(product.price, product.discount).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm text-gray-500 line-through">
|
||||
₹{product.price.toFixed(2)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-base sm:text-lg font-bold text-gray-900">
|
||||
₹{product.price.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* <span className="text-xs sm:text-sm text-gray-500">
|
||||
Stock: <span className={product.stock > 0 ? 'text-green-600' : 'text-red-600'}>{product.stock}</span>
|
||||
</span> */}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* B2C Feature - Add to Cart Button (Disabled for B2B mode) */}
|
||||
{isFeatureEnabled('cart') ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddToCart}
|
||||
disabled={product.stock === 0}
|
||||
className="flex-1 text-xs sm:text-sm h-8 sm:h-9"
|
||||
>
|
||||
<ShoppingCart className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
||||
<span className="hidden sm:inline">{product.stock === 0 ? 'Out of Stock' : 'Add to Cart'}</span>
|
||||
<span className="sm:hidden">{product.stock === 0 ? 'Out' : 'Add'}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRequestQuote}
|
||||
className="flex-1 text-xs sm:text-sm h-8 sm:h-9 bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<Eye className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Get Quote</span>
|
||||
<span className="sm:hidden">Quote</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleViewDetails}
|
||||
className="px-2 sm:px-3 h-8 sm:h-9"
|
||||
>
|
||||
<Eye className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick View Dialog */}
|
||||
<Dialog open={isQuickViewOpen} onOpenChange={setIsQuickViewOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">{product.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Product Image */}
|
||||
<div className="space-y-4">
|
||||
<div className="relative aspect-square overflow-hidden rounded-lg border">
|
||||
<OptimizedImage
|
||||
src={product.images[0] || 'https://images.pexels.com/photos/3683107/pexels-photo-3683107.jpeg'}
|
||||
alt={product.name}
|
||||
width={400}
|
||||
height={400}
|
||||
className="w-full h-full object-contain"
|
||||
quality={80}
|
||||
/>
|
||||
{product.discount > 0 && (
|
||||
<Badge className="absolute top-4 left-4 bg-red-500 hover:bg-red-600 text-white font-semibold">
|
||||
{product.discount}% OFF
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional Images if available */}
|
||||
{product.images.length > 1 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{product.images.slice(1, 5).map((image, idx) => (
|
||||
<div key={idx} className="aspect-square rounded border overflow-hidden">
|
||||
<OptimizedImage
|
||||
src={image}
|
||||
alt={`${product.name} ${idx + 2}`}
|
||||
width={100}
|
||||
height={100}
|
||||
className="w-full h-full object-contain"
|
||||
quality={60}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Product Details</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{product.description || 'Premium quality product from Padmaaja Rasooi Pvt. Ltd.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Category and Brand */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">Category</span>
|
||||
<p className="font-semibold">{product.category?.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">Brand</span>
|
||||
<p className="font-semibold">{product.brand || 'Padmaaja Rasooi'}</p>
|
||||
</div>
|
||||
{product.weight && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">Weight</span>
|
||||
<p className="font-semibold">{product.weight}</p>
|
||||
</div>
|
||||
)}
|
||||
{product.origin && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">Origin</span>
|
||||
<p className="font-semibold">{product.origin}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
{product.discount > 0 ? (
|
||||
<>
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
₹{getDiscountedPrice(product.price, product.discount).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-lg text-gray-500 line-through">
|
||||
₹{product.price.toFixed(2)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
₹{product.price.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per kg price in quick view */}
|
||||
{getPerKgPrice(product.price, product.weight, product.discount) && (
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
<span className="font-medium">Per kg: </span>
|
||||
₹{getPerKgPrice(product.price, product.weight, product.discount)}/kg
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stock Status */}
|
||||
<div className="mb-4">
|
||||
<span className="text-sm font-medium text-gray-500">Availability: </span>
|
||||
<span className={`font-semibold ${product.stock > 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{product.stock > 0 ? `In Stock (${product.stock} available)` : 'Out of Stock'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
{/* B2C Feature - Add to Cart Button (Disabled for B2B mode) */}
|
||||
{isFeatureEnabled('cart') ? (
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={product.stock === 0}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
{product.stock === 0 ? 'Out of Stock' : 'Add to Cart'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsQuickViewOpen(false)
|
||||
setIsInquiryFormOpen(true)
|
||||
}}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
||||
size="lg"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Request Quote
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsQuickViewOpen(false)
|
||||
router.push(`/products/${product.slug}`)
|
||||
}}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
View Full Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Rating in Quick View */}
|
||||
<div className="flex items-center space-x-2 pt-4 border-t">
|
||||
<div className="flex items-center">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-4 w-4 ${
|
||||
reviewStats.totalReviews > 0 && i < Math.round(reviewStats.averageRating)
|
||||
? 'text-yellow-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{reviewStats.totalReviews > 0
|
||||
? `${reviewStats.averageRating} (${reviewStats.totalReviews} reviews)`
|
||||
: 'No reviews yet'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* B2B Inquiry Form */}
|
||||
<B2BInquiryForm
|
||||
isOpen={isInquiryFormOpen}
|
||||
onOpenChange={setIsInquiryFormOpen}
|
||||
product={{
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
category: product.category,
|
||||
price: product.price,
|
||||
weight: product.weight || undefined
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
102
components/shop/ProductGrid.tsx
Normal file
102
components/shop/ProductGrid.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import ProductCard from '@/components/shop/ProductCard'
|
||||
import { Product } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ProductGridProps {
|
||||
products: Product[]
|
||||
categoryFilter?: string
|
||||
initialLoadCount?: number
|
||||
loadMoreCount?: number
|
||||
}
|
||||
|
||||
export default function ProductGrid({
|
||||
products,
|
||||
categoryFilter,
|
||||
initialLoadCount = 8,
|
||||
loadMoreCount = 4
|
||||
}: ProductGridProps) {
|
||||
const [displayCount, setDisplayCount] = useState(initialLoadCount)
|
||||
|
||||
const filteredProducts = categoryFilter
|
||||
? products.filter(product =>
|
||||
product.category.name.toLowerCase().includes(categoryFilter.toLowerCase()) ||
|
||||
product.name.toLowerCase().includes(categoryFilter.toLowerCase())
|
||||
)
|
||||
: products
|
||||
|
||||
const displayedProducts = filteredProducts.slice(0, displayCount)
|
||||
const hasMoreProducts = displayCount < filteredProducts.length
|
||||
const remainingProducts = filteredProducts.length - displayCount
|
||||
|
||||
const loadMore = () => {
|
||||
setDisplayCount(prev => Math.min(prev + loadMoreCount, filteredProducts.length))
|
||||
}
|
||||
|
||||
const showLess = () => {
|
||||
setDisplayCount(initialLoadCount)
|
||||
}
|
||||
|
||||
if (filteredProducts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 sm:py-12 px-4">
|
||||
<p className="text-gray-500 text-base sm:text-lg">No products found in this category.</p>
|
||||
<p className="text-gray-400 text-sm mt-2">Check back soon for new products!</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Products Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 md:gap-4 gap-8">
|
||||
{displayedProducts.map((product, index) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Load More / Show Less Controls */}
|
||||
{filteredProducts.length > initialLoadCount && (
|
||||
<div className="flex flex-col items-center gap-4 pt-4">
|
||||
{/* Products Counter */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing <span className="font-semibold">{displayedProducts.length}</span> of{' '}
|
||||
<span className="font-semibold">{filteredProducts.length}</span> products
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
{hasMoreProducts && (
|
||||
<Button
|
||||
onClick={loadMore}
|
||||
variant="outline"
|
||||
className="px-6 py-2 rounded-lg border-gray-300 hover:border-gray-400 transition-colors"
|
||||
>
|
||||
Load {Math.min(loadMoreCount, remainingProducts)} More
|
||||
{remainingProducts > loadMoreCount && ` (${remainingProducts} remaining)`}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{displayCount > initialLoadCount && (
|
||||
<Button
|
||||
onClick={showLess}
|
||||
variant="ghost"
|
||||
className="px-6 py-2 text-gray-600 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
Show Less
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
components/ui/LazyLoader.tsx
Normal file
127
components/ui/LazyLoader.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, lazy, ComponentType } from 'react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface LazyComponentProps {
|
||||
fallback?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Generic lazy loading wrapper
|
||||
export function createLazyComponent<T extends ComponentType<any>>(
|
||||
importFunction: () => Promise<{ default: T }>,
|
||||
fallback?: React.ReactNode
|
||||
) {
|
||||
const LazyComponent = lazy(importFunction)
|
||||
|
||||
return function LazyWrapper(props: React.ComponentProps<T> & LazyComponentProps) {
|
||||
const { fallback: customFallback, className, ...componentProps } = props
|
||||
|
||||
const defaultFallback = (
|
||||
<div className={className}>
|
||||
<Skeleton className="w-full h-64 rounded-lg" />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Suspense fallback={customFallback || defaultFallback}>
|
||||
<LazyComponent {...(componentProps as React.ComponentProps<T>)} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Product Grid Skeleton
|
||||
export function ProductGridSkeleton({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="space-y-3">
|
||||
<Skeleton className="w-full h-48 rounded-lg" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Chart Skeleton
|
||||
export function ChartSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-64 w-full rounded-lg" />
|
||||
<div className="flex space-x-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Dashboard Stats Skeleton
|
||||
export function DashboardStatsSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="p-6 bg-white rounded-lg border space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Table Skeleton
|
||||
export function TableSkeleton({ rows = 5, cols = 4 }: { rows?: number, cols?: number }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex space-x-4">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div key={rowIndex} className="flex space-x-4">
|
||||
{Array.from({ length: cols }).map((_, colIndex) => (
|
||||
<Skeleton key={colIndex} className="h-8 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Form Skeleton
|
||||
export function FormSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
<div className="flex space-x-4">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
components/ui/OptimizedImage.tsx
Normal file
103
components/ui/OptimizedImage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OptimizedImageProps {
|
||||
src: string
|
||||
alt: string
|
||||
width: number
|
||||
height: number
|
||||
className?: string
|
||||
priority?: boolean
|
||||
fill?: boolean
|
||||
sizes?: string
|
||||
quality?: number
|
||||
placeholder?: 'blur' | 'empty'
|
||||
blurDataURL?: string
|
||||
}
|
||||
|
||||
// Generate a simple blur placeholder
|
||||
const shimmer = (w: number, h: number) => `
|
||||
<svg width="${w}" height="${h}" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<linearGradient id="g">
|
||||
<stop stop-color="#f3f4f6" offset="20%" />
|
||||
<stop stop-color="#e5e7eb" offset="50%" />
|
||||
<stop stop-color="#f3f4f6" offset="70%" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="${w}" height="${h}" fill="#f3f4f6" />
|
||||
<rect id="r" width="${w}" height="${h}" fill="url(#g)" />
|
||||
<animate xlink:href="#r" attributeName="x" from="-${w}" to="${w}" dur="1s" repeatCount="indefinite" />
|
||||
</svg>`
|
||||
|
||||
const toBase64 = (str: string) =>
|
||||
typeof window === 'undefined'
|
||||
? Buffer.from(str).toString('base64')
|
||||
: window.btoa(str)
|
||||
|
||||
export default function OptimizedImage({
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
priority = false,
|
||||
fill = false,
|
||||
sizes,
|
||||
quality = 75,
|
||||
placeholder = 'blur',
|
||||
blurDataURL,
|
||||
...props
|
||||
}: OptimizedImageProps) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
|
||||
// Default fallback image for products
|
||||
const fallbackSrc = '/logo.png'
|
||||
|
||||
// Generate blur placeholder if not provided
|
||||
const defaultBlurDataURL = `data:image/svg+xml;base64,${toBase64(shimmer(width, height))}`
|
||||
|
||||
const handleError = () => {
|
||||
setImageError(true)
|
||||
}
|
||||
|
||||
const handleLoad = () => {
|
||||
setImageLoaded(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative overflow-hidden", className)}>
|
||||
<Image
|
||||
src={imageError ? fallbackSrc : src}
|
||||
alt={alt}
|
||||
width={fill ? undefined : width}
|
||||
height={fill ? undefined : height}
|
||||
fill={fill}
|
||||
priority={priority}
|
||||
quality={quality}
|
||||
sizes={sizes || fill ? '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw' : undefined}
|
||||
placeholder={placeholder}
|
||||
blurDataURL={blurDataURL || defaultBlurDataURL}
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
imageLoaded ? "opacity-100" : "opacity-0",
|
||||
fill ? "object-cover" : "w-full h-full object-cover"
|
||||
)}
|
||||
onError={handleError}
|
||||
onLoad={handleLoad}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{/* Loading shimmer effect */}
|
||||
{!imageLoaded && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 animate-pulse">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/60 to-transparent animate-shimmer"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
components/ui/accordion.tsx
Normal file
58
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
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 };
|
||||
141
components/ui/alert-dialog.tsx
Normal file
141
components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
59
components/ui/alert.tsx
Normal file
59
components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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 };
|
||||
7
components/ui/aspect-ratio.tsx
Normal file
7
components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
|
||||
export { AspectRatio };
|
||||
50
components/ui/avatar.tsx
Normal file
50
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
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 };
|
||||
36
components/ui/badge.tsx
Normal file
36
components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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 };
|
||||
56
components/ui/button.tsx
Normal file
56
components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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-colors 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/90",
|
||||
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/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
66
components/ui/calendar.tsx
Normal file
66
components/ui/calendar.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||
month: 'space-y-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
|
||||
),
|
||||
nav_button_previous: 'absolute left-1',
|
||||
nav_button_next: 'absolute right-1',
|
||||
table: 'w-full border-collapse space-y-1',
|
||||
head_row: 'flex',
|
||||
head_cell:
|
||||
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||
row: 'flex w-full mt-2',
|
||||
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
||||
day: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
|
||||
),
|
||||
day_range_end: 'day-range-end',
|
||||
day_selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
day_today: 'bg-accent text-accent-foreground',
|
||||
day_outside:
|
||||
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||
day_disabled: 'text-muted-foreground opacity-50',
|
||||
day_range_middle:
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
day_hidden: 'invisible',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = 'Calendar';
|
||||
|
||||
export { Calendar };
|
||||
86
components/ui/card.tsx
Normal file
86
components/ui/card.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
262
components/ui/carousel.tsx
Normal file
262
components/ui/carousel.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from 'embla-carousel-react';
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCarousel must be used within a <Carousel />');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = 'horizontal',
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === 'horizontal' ? 'x' : 'y',
|
||||
},
|
||||
plugins
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(api);
|
||||
api.on('reInit', onSelect);
|
||||
api.on('select', onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off('select', onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn('relative', className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
Carousel.displayName = 'Carousel';
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex',
|
||||
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CarouselContent.displayName = 'CarouselContent';
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
'min-w-0 shrink-0 grow-0 basis-full',
|
||||
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
CarouselItem.displayName = 'CarouselItem';
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? '-left-12 top-1/2 -translate-y-1/2'
|
||||
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselPrevious.displayName = 'CarouselPrevious';
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? '-right-12 top-1/2 -translate-y-1/2'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselNext.displayName = 'CarouselNext';
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
30
components/ui/checkbox.tsx
Normal file
30
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
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 };
|
||||
11
components/ui/collapsible.tsx
Normal file
11
components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
155
components/ui/command.tsx
Normal file
155
components/ui/command.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { type DialogProps } from '@radix-ui/react-dialog';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
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="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 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>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...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 data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground 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,
|
||||
};
|
||||
356
components/ui/csv-export.tsx
Normal file
356
components/ui/csv-export.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Download, FileText, CheckCircle, AlertTriangle } from 'lucide-react'
|
||||
import Papa from 'papaparse'
|
||||
|
||||
interface ExportColumn {
|
||||
key: string
|
||||
label: string
|
||||
transform?: (value: any, row?: any) => string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
interface CsvExportProps {
|
||||
title: string
|
||||
description: string
|
||||
columns: ExportColumn[]
|
||||
onExport: (
|
||||
selectedColumns: string[],
|
||||
filters?: any,
|
||||
onProgress?: (progress: number) => void
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: any[]
|
||||
message?: string
|
||||
}>
|
||||
filters?: {
|
||||
label: string
|
||||
key: string
|
||||
options: { value: string; label: string }[]
|
||||
}[]
|
||||
maxRecords?: number
|
||||
filename?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function CsvExport({
|
||||
title,
|
||||
description,
|
||||
columns,
|
||||
onExport,
|
||||
filters = [],
|
||||
maxRecords = 10000,
|
||||
filename,
|
||||
children
|
||||
}: CsvExportProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [selectedColumns, setSelectedColumns] = useState<string[]>(
|
||||
columns.filter(col => col.required).map(col => col.key)
|
||||
)
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({})
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [result, setResult] = useState<{
|
||||
success: boolean
|
||||
message?: string
|
||||
recordCount?: number
|
||||
} | null>(null)
|
||||
|
||||
const handleColumnToggle = (columnKey: string, checked: boolean) => {
|
||||
const column = columns.find(col => col.key === columnKey)
|
||||
if (column?.required) return // Don't allow unchecking required columns
|
||||
|
||||
if (checked) {
|
||||
setSelectedColumns(prev => [...prev, columnKey])
|
||||
} else {
|
||||
setSelectedColumns(prev => prev.filter(key => key !== columnKey))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedColumns(columns.map(col => col.key))
|
||||
} else {
|
||||
setSelectedColumns(columns.filter(col => col.required).map(col => col.key))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilterChange = (filterKey: string, value: string) => {
|
||||
setFilterValues(prev => ({
|
||||
...prev,
|
||||
[filterKey]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const generateFilename = () => {
|
||||
if (filename) return filename
|
||||
const timestamp = new Date().toISOString().split('T')[0]
|
||||
return `${title.toLowerCase().replace(/\s+/g, '_')}_export_${timestamp}.csv`
|
||||
}
|
||||
|
||||
const downloadCsv = (data: any[]) => {
|
||||
const selectedColumnData = columns.filter(col => selectedColumns.includes(col.key))
|
||||
|
||||
// Prepare headers
|
||||
const headers = selectedColumnData.map(col => col.label)
|
||||
|
||||
// Prepare data rows
|
||||
const rows = data.map(row => {
|
||||
return selectedColumnData.map(col => {
|
||||
const value = row[col.key]
|
||||
if (col.transform) {
|
||||
return col.transform(value, row)
|
||||
}
|
||||
return value?.toString() || ''
|
||||
})
|
||||
})
|
||||
|
||||
// Generate CSV
|
||||
const csvContent = Papa.unparse([headers, ...rows])
|
||||
|
||||
// Download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', generateFilename())
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (selectedColumns.length === 0) {
|
||||
setResult({
|
||||
success: false,
|
||||
message: 'Please select at least one column to export'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setExporting(true)
|
||||
setProgress(0)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const exportResult = await onExport(selectedColumns, filterValues, setProgress)
|
||||
|
||||
if (exportResult.success && exportResult.data) {
|
||||
downloadCsv(exportResult.data)
|
||||
setResult({
|
||||
success: true,
|
||||
message: 'Export completed successfully',
|
||||
recordCount: exportResult.data.length
|
||||
})
|
||||
} else {
|
||||
setResult({
|
||||
success: false,
|
||||
message: exportResult.message || 'Export failed'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Export failed'
|
||||
})
|
||||
} finally {
|
||||
setExporting(false)
|
||||
setProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
const resetDialog = () => {
|
||||
setResult(null)
|
||||
setProgress(0)
|
||||
setFilterValues({})
|
||||
}
|
||||
|
||||
const selectedCount = selectedColumns.length
|
||||
const allSelected = selectedCount === columns.length
|
||||
const someSelected = selectedCount > 0 && selectedCount < columns.length
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => {
|
||||
setIsOpen(open)
|
||||
if (!open) resetDialog()
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
{children || (
|
||||
<Button variant="outline">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export CSV
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
{filters.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">Filters</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filters.map(filter => (
|
||||
<div key={filter.key} className="space-y-2">
|
||||
<Label>{filter.label}</Label>
|
||||
<Select
|
||||
value={filterValues[filter.key] || ''}
|
||||
onValueChange={(value) => handleFilterChange(filter.key, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select ${filter.label.toLowerCase()}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All</SelectItem>
|
||||
{filter.options.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column Selection */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">Select Columns to Export</h4>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="select-all"
|
||||
checked={allSelected}
|
||||
ref={(ref) => {
|
||||
if (ref && ref instanceof HTMLButtonElement) {
|
||||
(ref as any).indeterminate = someSelected
|
||||
}
|
||||
}}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<Label htmlFor="select-all" className="text-sm">
|
||||
Select All ({selectedCount}/{columns.length})
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-60 overflow-y-auto border rounded-lg p-4">
|
||||
{columns.map(column => (
|
||||
<div key={column.key} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={column.key}
|
||||
checked={selectedColumns.includes(column.key)}
|
||||
onCheckedChange={(checked) => handleColumnToggle(column.key, !!checked)}
|
||||
disabled={column.required}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={column.key}
|
||||
className={`text-sm ${column.required ? 'font-medium' : ''}`}
|
||||
>
|
||||
{column.label}
|
||||
{column.required && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Info */}
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<FileText className="h-5 w-5 text-blue-500 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-blue-900">Export Information</p>
|
||||
<ul className="text-blue-700 space-y-1 mt-1">
|
||||
<li>• {selectedCount} columns selected</li>
|
||||
<li>• Maximum {maxRecords.toLocaleString()} records</li>
|
||||
<li>• File format: CSV (UTF-8)</li>
|
||||
<li>• Filename: {generateFilename()}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Progress */}
|
||||
{exporting && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Exporting...</span>
|
||||
<span className="text-sm text-gray-500">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export Result */}
|
||||
{result && (
|
||||
<Alert variant={result.success ? 'default' : 'destructive'}>
|
||||
{result.success ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
)}
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{result.message}</p>
|
||||
{result.recordCount !== undefined && (
|
||||
<p className="text-sm">
|
||||
Exported {result.recordCount.toLocaleString()} records
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end space-x-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={exporting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={selectedColumns.length === 0 || exporting}
|
||||
>
|
||||
{exporting ? 'Exporting...' : 'Export Data'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
429
components/ui/csv-import.tsx
Normal file
429
components/ui/csv-import.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Upload, Download, FileText, AlertTriangle, CheckCircle, X } from 'lucide-react'
|
||||
import Papa from 'papaparse'
|
||||
|
||||
interface ColumnDefinition {
|
||||
key: string
|
||||
label: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
interface CsvImportProps {
|
||||
title: string
|
||||
description: string
|
||||
templateColumns: Array<ColumnDefinition | string>
|
||||
onImport: (data: any[], onProgress?: (progress: number) => void) => Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
successCount?: number
|
||||
errorCount?: number
|
||||
errors?: string[]
|
||||
}>
|
||||
sampleData?: Record<string, any>[]
|
||||
validationRules?: {
|
||||
required?: string[]
|
||||
formats?: Record<string, (value: any) => boolean>
|
||||
transforms?: Record<string, (value: any) => any>
|
||||
}
|
||||
maxFileSize?: number // in MB
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function CsvImport({
|
||||
title,
|
||||
description,
|
||||
templateColumns,
|
||||
onImport,
|
||||
sampleData = [],
|
||||
validationRules = {},
|
||||
maxFileSize = 10,
|
||||
children
|
||||
}: CsvImportProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [result, setResult] = useState<{
|
||||
success: boolean
|
||||
message: string
|
||||
successCount?: number
|
||||
errorCount?: number
|
||||
errors?: string[]
|
||||
} | null>(null)
|
||||
const [preview, setPreview] = useState<any[]>([])
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Normalize templateColumns to always be ColumnDefinition objects
|
||||
const normalizedColumns: ColumnDefinition[] = templateColumns.map(col =>
|
||||
typeof col === 'string'
|
||||
? { key: col, label: col, required: false }
|
||||
: col
|
||||
)
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = normalizedColumns.map(col => col.label)
|
||||
const csvContent = Papa.unparse([
|
||||
headers,
|
||||
...sampleData.map(row => normalizedColumns.map(col => row[col.key] || ''))
|
||||
])
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', `${title.toLowerCase().replace(/\s+/g, '_')}_template.csv`)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const validateData = (data: any[]): string[] => {
|
||||
const errors: string[] = []
|
||||
const { required = [], formats = {}, transforms = {} } = validationRules
|
||||
|
||||
// Get required fields from template columns and validation rules
|
||||
const requiredFields = [
|
||||
...required,
|
||||
...normalizedColumns.filter(col => col.required).map(col => col.key)
|
||||
]
|
||||
|
||||
data.forEach((row, index) => {
|
||||
// Check required fields
|
||||
requiredFields.forEach(field => {
|
||||
if (!row[field] || row[field].toString().trim() === '') {
|
||||
const column = normalizedColumns.find(col => col.key === field)
|
||||
const fieldLabel = column ? column.label : field
|
||||
errors.push(`Row ${index + 1}: ${fieldLabel} is required`)
|
||||
}
|
||||
})
|
||||
|
||||
// Check format validations
|
||||
Object.entries(formats).forEach(([field, validator]) => {
|
||||
if (row[field] && !validator(row[field])) {
|
||||
const column = normalizedColumns.find(col => col.key === field)
|
||||
const fieldLabel = column ? column.label : field
|
||||
errors.push(`Row ${index + 1}: ${fieldLabel} has invalid format`)
|
||||
}
|
||||
})
|
||||
|
||||
// Apply transformations
|
||||
Object.entries(transforms).forEach(([field, transformer]) => {
|
||||
if (row[field]) {
|
||||
try {
|
||||
row[field] = transformer(row[field])
|
||||
} catch (error) {
|
||||
const column = normalizedColumns.find(col => col.key === field)
|
||||
const fieldLabel = column ? column.label : field
|
||||
errors.push(`Row ${index + 1}: ${fieldLabel} transformation failed`)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0]
|
||||
if (!selectedFile) return
|
||||
|
||||
// Check file size
|
||||
if (selectedFile.size > maxFileSize * 1024 * 1024) {
|
||||
setValidationErrors([`File size must be less than ${maxFileSize}MB`])
|
||||
return
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if (!selectedFile.name.toLowerCase().endsWith('.csv')) {
|
||||
setValidationErrors(['Please select a CSV file'])
|
||||
return
|
||||
}
|
||||
|
||||
setFile(selectedFile)
|
||||
setValidationErrors([])
|
||||
setResult(null)
|
||||
|
||||
// Parse and preview the file
|
||||
Papa.parse(selectedFile, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
if (results.errors.length > 0) {
|
||||
setValidationErrors(results.errors.map(err => err.message))
|
||||
return
|
||||
}
|
||||
|
||||
const data = results.data as any[]
|
||||
const errors = validateData(data)
|
||||
|
||||
if (errors.length > 0) {
|
||||
setValidationErrors(errors.slice(0, 10)) // Show first 10 errors
|
||||
return
|
||||
}
|
||||
|
||||
setPreview(data.slice(0, 5)) // Show first 5 rows
|
||||
setValidationErrors([])
|
||||
},
|
||||
error: (error) => {
|
||||
setValidationErrors([error.message])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) return
|
||||
|
||||
setImporting(true)
|
||||
setProgress(0)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: async (results) => {
|
||||
try {
|
||||
const data = results.data as any[]
|
||||
const errors = validateData(data)
|
||||
|
||||
if (errors.length > 0) {
|
||||
setResult({
|
||||
success: false,
|
||||
message: 'Validation failed',
|
||||
errors: errors
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = await onImport(data, setProgress)
|
||||
setResult(result)
|
||||
|
||||
if (result.success) {
|
||||
setFile(null)
|
||||
setPreview([])
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Import failed'
|
||||
})
|
||||
} finally {
|
||||
setImporting(false)
|
||||
setProgress(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
setResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Import failed'
|
||||
})
|
||||
setImporting(false)
|
||||
setProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
const resetDialog = () => {
|
||||
setFile(null)
|
||||
setPreview([])
|
||||
setValidationErrors([])
|
||||
setResult(null)
|
||||
setProgress(0)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => {
|
||||
setIsOpen(open)
|
||||
if (!open) resetDialog()
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
{children || (
|
||||
<Button variant="outline">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import CSV
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Download Template */}
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<FileText className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<h4 className="font-medium">Download Template</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
Download CSV template with required columns
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={downloadTemplate}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="csv-file">Select CSV File</Label>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
id="csv-file"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
disabled={importing}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Maximum file size: {maxFileSize}MB. Only CSV files are supported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Validation Errors */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Validation Errors:</p>
|
||||
<ul className="list-disc list-inside text-sm space-y-1">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{preview.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Preview (First 5 rows)</h4>
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{normalizedColumns.map(col => (
|
||||
<th key={col.key} className="px-3 py-2 text-left font-medium">
|
||||
{col.label}
|
||||
{col.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map((row, index) => (
|
||||
<tr key={index} className="border-t">
|
||||
{normalizedColumns.map(col => (
|
||||
<td key={col.key} className="px-3 py-2">
|
||||
{row[col.key]?.toString() || ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Progress */}
|
||||
{importing && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Importing...</span>
|
||||
<span className="text-sm text-gray-500">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Result */}
|
||||
{result && (
|
||||
<Alert variant={result.success ? 'default' : 'destructive'}>
|
||||
{result.success ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<X className="h-4 w-4" />
|
||||
)}
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">{result.message}</p>
|
||||
{result.successCount !== undefined && (
|
||||
<p className="text-sm">
|
||||
Successfully imported: {result.successCount} records
|
||||
</p>
|
||||
)}
|
||||
{result.errorCount !== undefined && result.errorCount > 0 && (
|
||||
<p className="text-sm">
|
||||
Failed to import: {result.errorCount} records
|
||||
</p>
|
||||
)}
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Errors:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{result.errors.slice(0, 10).map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
{result.errors.length > 10 && (
|
||||
<li>... and {result.errors.length - 10} more errors</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end space-x-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={importing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!file || validationErrors.length > 0 || importing}
|
||||
>
|
||||
{importing ? 'Importing...' : 'Import Data'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
122
components/ui/dialog.tsx
Normal file
122
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
200
components/ui/dropdown-menu.tsx
Normal file
200
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
275
components/ui/file-upload.tsx
Normal file
275
components/ui/file-upload.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useFileUpload, type UploadOptions, type UploadResult } from '@/hooks/use-file-upload'
|
||||
import {
|
||||
Upload,
|
||||
X,
|
||||
FileImage,
|
||||
File as FileIcon,
|
||||
Trash2,
|
||||
Eye
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface FileUploadProps {
|
||||
onUploadComplete?: (results: UploadResult[]) => void
|
||||
onFilesChange?: (files: File[]) => void
|
||||
onDelete?: (url: string) => void
|
||||
options?: UploadOptions
|
||||
multiple?: boolean
|
||||
accept?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
showPreview?: boolean
|
||||
existingFiles?: string[]
|
||||
}
|
||||
|
||||
export function FileUpload({
|
||||
onUploadComplete,
|
||||
onFilesChange,
|
||||
onDelete,
|
||||
options = {},
|
||||
multiple = true,
|
||||
accept = 'image/*',
|
||||
disabled = false,
|
||||
className,
|
||||
showPreview = true,
|
||||
existingFiles = []
|
||||
}: FileUploadProps) {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
||||
const [uploadedFiles, setUploadedFiles] = useState<string[]>(existingFiles)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { uploadFiles, deleteFile, uploading, progress } = useFileUpload()
|
||||
|
||||
const handleFileSelect = useCallback((files: FileList | null) => {
|
||||
if (!files) return
|
||||
|
||||
const fileArray = Array.from(files)
|
||||
setSelectedFiles(prev => multiple ? [...prev, ...fileArray] : fileArray)
|
||||
onFilesChange?.(fileArray)
|
||||
}, [multiple, onFilesChange])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
|
||||
if (disabled) return
|
||||
|
||||
const files = e.dataTransfer.files
|
||||
handleFileSelect(files)
|
||||
}, [disabled, handleFileSelect])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!disabled) setDragOver(true)
|
||||
}, [disabled])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
}, [])
|
||||
|
||||
const removeSelectedFile = useCallback((index: number) => {
|
||||
setSelectedFiles(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (selectedFiles.length === 0) return
|
||||
|
||||
try {
|
||||
const results = await uploadFiles(selectedFiles, options)
|
||||
const urls = results.map(r => r.url)
|
||||
|
||||
setUploadedFiles(prev => [...prev, ...urls])
|
||||
setSelectedFiles([])
|
||||
onUploadComplete?.(results)
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
}
|
||||
}, [selectedFiles, uploadFiles, options, onUploadComplete])
|
||||
|
||||
const handleDeleteUploadedFile = useCallback(async (url: string) => {
|
||||
try {
|
||||
await deleteFile(url)
|
||||
setUploadedFiles(prev => prev.filter(f => f !== url))
|
||||
onDelete?.(url)
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error)
|
||||
}
|
||||
}, [deleteFile, onDelete])
|
||||
|
||||
const getFileIcon = (file: File) => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
return <FileImage className="h-8 w-8 text-blue-500" />
|
||||
}
|
||||
return <FileIcon className="h-8 w-8 text-gray-500" />
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Upload Area */}
|
||||
<Card
|
||||
className={cn(
|
||||
'border-2 border-dashed transition-colors cursor-pointer',
|
||||
dragOver ? 'border-blue-500 bg-blue-50' : 'border-gray-300',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||
>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||
<Upload className="h-12 w-12 text-gray-400 mb-4" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-medium">
|
||||
{dragOver ? 'Drop files here' : 'Click to upload or drag & drop'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{accept === 'image/*' ? 'Images only' : 'Various file types supported'}
|
||||
{options.maxSize && ` • Max ${options.maxSize}MB per file`}
|
||||
{options.maxFiles && ` • Max ${options.maxFiles} files`}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Selected Files */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">Selected Files ({selectedFiles.length})</h4>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedFiles([])}
|
||||
disabled={uploading}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || selectedFiles.length === 0}
|
||||
size="sm"
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload Files'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={progress} className="w-full" />
|
||||
<p className="text-sm text-gray-500 text-center">{progress}% uploaded</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${index}`}
|
||||
className="flex items-center gap-3 p-3 border rounded-lg bg-gray-50"
|
||||
>
|
||||
{getFileIcon(file)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{file.name}</p>
|
||||
<p className="text-sm text-gray-500">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeSelectedFile(index)}
|
||||
disabled={uploading}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploaded Files */}
|
||||
{uploadedFiles.length > 0 && showPreview && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Uploaded Files ({uploadedFiles.length})</h4>
|
||||
<div className="grid gap-2">
|
||||
{uploadedFiles.map((url, index) => (
|
||||
<div
|
||||
key={`uploaded-${index}`}
|
||||
className="flex items-center gap-3 p-3 border rounded-lg bg-green-50"
|
||||
>
|
||||
{url.match(/\.(jpg|jpeg|png|gif|webp)$/i) ? (
|
||||
<div className="relative h-12 w-12 rounded overflow-hidden">
|
||||
<Image
|
||||
src={url}
|
||||
alt="Uploaded file"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FileIcon className="h-8 w-8 text-gray-500" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
{url.split('/').pop()?.split('?')[0] || 'Uploaded file'}
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Uploaded
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUploadedFile(url)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
components/ui/form.tsx
Normal file
179
components/ui/form.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} 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, formState } = useFormContext();
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && 'text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = 'FormControl';
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-sm font-medium text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
25
components/ui/input.tsx
Normal file
25
components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
21
components/ui/loading.tsx
Normal file
21
components/ui/loading.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface LoadingProps {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
text?: string
|
||||
}
|
||||
|
||||
export function Loading({ size = 'md', text }: LoadingProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Loader2 className={`animate-spin ${sizeClasses[size]}`} />
|
||||
{text && <span className="text-sm text-muted-foreground">{text}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
236
components/ui/menubar.tsx
Normal file
236
components/ui/menubar.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as MenubarPrimitive from '@radix-ui/react-menubar';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const MenubarMenu = MenubarPrimitive.Menu;
|
||||
|
||||
const MenubarGroup = MenubarPrimitive.Group;
|
||||
|
||||
const MenubarPortal = MenubarPrimitive.Portal;
|
||||
|
||||
const MenubarSub = MenubarPrimitive.Sub;
|
||||
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 items-center space-x-1 rounded-md border bg-background p-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(
|
||||
(
|
||||
{ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
));
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
));
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
MenubarShortcut.displayname = 'MenubarShortcut';
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
};
|
||||
128
components/ui/navigation-menu.tsx
Normal file
128
components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
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,
|
||||
};
|
||||
101
components/ui/page-loader.tsx
Normal file
101
components/ui/page-loader.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface PageLoaderProps {
|
||||
onLoadingComplete: () => void
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export default function PageLoader({ onLoadingComplete, duration = 800 }: PageLoaderProps) {
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
// Progress bar animation - faster completion
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress(prev => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(progressInterval)
|
||||
return 100
|
||||
}
|
||||
return prev + 12.5 // Will complete in 0.8 seconds (100/12.5 * 100ms)
|
||||
})
|
||||
}, 100)
|
||||
|
||||
// Complete loading after duration
|
||||
const loadingTimeout = setTimeout(() => {
|
||||
onLoadingComplete()
|
||||
}, duration)
|
||||
|
||||
return () => {
|
||||
clearInterval(progressInterval)
|
||||
clearTimeout(loadingTimeout)
|
||||
}
|
||||
}, [duration, onLoadingComplete])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50"
|
||||
>
|
||||
<div className="text-center space-y-8 max-w-4xl mx-auto px-6">
|
||||
{/* Kashmina Logo */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className="flex justify-center mb-8"
|
||||
>
|
||||
<div className="relative w-32 h-32 md:w-40 md:h-40">
|
||||
<Image
|
||||
src="/kashmina-logo.png"
|
||||
alt="Kashmina Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
sizes="(max-width: 768px) 128px, 160px"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Welcome Heading */}
|
||||
<motion.div
|
||||
initial={{ y: 30, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold bg-gradient-to-r from-amber-700 via-orange-600 to-red-600 bg-clip-text text-transparent leading-tight">
|
||||
Welcome to Padmaaja Rasooi
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-slate-600 font-medium">
|
||||
Premium Rice • Authentic Quality • From Farm to Kitchen
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.4 }}
|
||||
className="w-full max-w-md mx-auto space-y-3"
|
||||
>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden border border-slate-300/50">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-amber-500 via-orange-500 to-red-500 rounded-full shadow-sm"
|
||||
style={{ width: `${Math.round(progress)}%` }}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 font-medium">
|
||||
Loading your premium rice experience...
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
117
components/ui/pagination.tsx
Normal file
117
components/ui/pagination.tsx
Normal 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,
|
||||
};
|
||||
28
components/ui/progress.tsx
Normal file
28
components/ui/progress.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
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 };
|
||||
48
components/ui/scroll-area.tsx
Normal file
48
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
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 }
|
||||
160
components/ui/select.tsx
Normal file
160
components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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',
|
||||
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)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
31
components/ui/separator.tsx
Normal file
31
components/ui/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
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 };
|
||||
140
components/ui/sheet.tsx
Normal file
140
components/ui/sheet.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom:
|
||||
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
right:
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: 'right',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = 'SheetFooter';
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
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 };
|
||||
31
components/ui/sonner.tsx
Normal file
31
components/ui/sonner.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton:
|
||||
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton:
|
||||
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
29
components/ui/switch.tsx
Normal file
29
components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
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 };
|
||||
117
components/ui/table.tsx
Normal file
117
components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
55
components/ui/tabs.tsx
Normal file
55
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
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 };
|
||||
24
components/ui/textarea.tsx
Normal file
24
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
129
components/ui/toast.tsx
Normal file
129
components/ui/toast.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
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,
|
||||
};
|
||||
35
components/ui/toaster.tsx
Normal file
35
components/ui/toaster.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
45
components/ui/toggle.tsx
Normal file
45
components/ui/toggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TogglePrimitive from '@radix-ui/react-toggle';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const toggleVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground 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=on]:bg-accent data-[state=on]:text-accent-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline:
|
||||
'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-3',
|
||||
sm: 'h-9 px-2.5',
|
||||
lg: 'h-11 px-5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName;
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
30
components/ui/tooltip.tsx
Normal file
30
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
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 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Reference in New Issue
Block a user