first commit
This commit is contained in:
21
components/AnimatedGradientText.tsx
Normal file
21
components/AnimatedGradientText.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface AnimatedGradientTextProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AnimatedGradientText = ({ text, className = "" }: AnimatedGradientTextProps) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className={`inline-block ${className}`}
|
||||
>
|
||||
<span className="bg-gradient-to-r from-[#E5195E] via-purple-400 to-[#E5195E] bg-clip-text text-transparent animate-pulse">
|
||||
{text}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
139
components/AppSuccessMetrics.tsx
Normal file
139
components/AppSuccessMetrics.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ImageWithFallback } from "./figma/ImageWithFallback";
|
||||
// import successMetricsImage from 'figma:asset/619c58bb9b76889672d43420adc0dd6ef9ef21f6.png';
|
||||
|
||||
const successMetricsImage =
|
||||
"https://images.unsplash.com/photo-1559757148-5c350d0d3c56?w=400&h=300&fit=crop&auto=format";
|
||||
|
||||
const AppSuccessMetrics = () => {
|
||||
const metrics = [
|
||||
{
|
||||
value: "75+",
|
||||
label: "App Developed",
|
||||
description: "Successful mobile applications delivered",
|
||||
},
|
||||
{
|
||||
value: "25+",
|
||||
label: "App Deployed",
|
||||
description: "Live applications in production",
|
||||
},
|
||||
{
|
||||
value: "3M+",
|
||||
label: "App downloads",
|
||||
description: "Total downloads across all platforms",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-20 lg:py-32 bg-black relative overflow-hidden">
|
||||
<div className="container mx-auto px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-semibold text-white mb-6 leading-tight">
|
||||
Proven Success in{" "}
|
||||
<span className="text-accent">Mobile Innovation</span>
|
||||
</h2>
|
||||
<p className="text-xl text-gray-300 max-w-3xl mx-auto leading-relaxed">
|
||||
Our portfolio speaks for itself — from concept to launch, we
|
||||
deliver exceptional mobile experiences that users love and
|
||||
businesses rely on.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Main Visual Section */}
|
||||
<div className="relative">
|
||||
{/* iPhone Mockups Display */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="flex justify-center mb-16"
|
||||
>
|
||||
<div className="relative max-w-4xl w-full">
|
||||
<ImageWithFallback
|
||||
src={successMetricsImage}
|
||||
alt="Three iPhone mockups showcasing different mobile applications with success metrics"
|
||||
className="w-full h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Performance Statistics */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-12 max-w-4xl mx-auto"
|
||||
>
|
||||
{metrics.map((metric, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
delay: 0.6 + index * 0.1,
|
||||
}}
|
||||
viewport={{ once: true }}
|
||||
className="text-center group"
|
||||
>
|
||||
{/* Large Metric Number */}
|
||||
<div className="mb-4">
|
||||
<span className="text-6xl lg:text-7xl font-bold text-white group-hover:text-accent transition-colors duration-300">
|
||||
{metric.value}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Metric Label */}
|
||||
<h3 className="text-lg lg:text-xl font-semibold text-gray-300 mb-2">
|
||||
{metric.label}
|
||||
</h3>
|
||||
|
||||
{/* Metric Description */}
|
||||
<p className="text-sm text-gray-400 leading-relaxed">
|
||||
{metric.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Supporting Content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mt-16"
|
||||
>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto leading-relaxed">
|
||||
Every project we deliver combines cutting-edge technology with
|
||||
user-centered design, resulting in mobile applications that not
|
||||
only meet but exceed expectations across industries and platforms.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Decorative Elements */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{/* Subtle gradient orbs for depth */}
|
||||
<div className="absolute top-20 left-10 w-80 h-80 bg-accent/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-20 right-10 w-80 h-80 bg-blue-500/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-purple-500/3 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { AppSuccessMetrics };
|
||||
292
components/CarouselTestimonials.tsx
Normal file
292
components/CarouselTestimonials.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
import { Button } from "./ui/button";
|
||||
import { Star } from "lucide-react";
|
||||
import { ImageWithFallback } from "./figma/ImageWithFallback";
|
||||
|
||||
// High-quality Clutch logo placeholder
|
||||
const clutchLogo = "https://images.unsplash.com/photo-1560472354-b33ff0c44a43?w=120&h=60&fit=crop&auto=format";
|
||||
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Sarah Chen",
|
||||
position: "CTO",
|
||||
company: "FinTech Innovations",
|
||||
image: "https://images.unsplash.com/photo-1494790108755-2616b332c5cd?w=150&h=150&fit=crop&auto=format",
|
||||
rating: 5,
|
||||
text: "WDI transformed our legacy banking system into a modern, scalable platform. Their expertise in financial technology is unmatched.",
|
||||
projectType: "Banking Platform Modernization"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Michael Rodriguez",
|
||||
position: "Founder & CEO",
|
||||
company: "HealthTech Solutions",
|
||||
image: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&auto=format",
|
||||
rating: 5,
|
||||
text: "The mobile health app WDI developed has revolutionized patient care delivery. Exceptional attention to healthcare compliance and user experience.",
|
||||
projectType: "Healthcare Mobile App"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Emily Watson",
|
||||
position: "VP of Digital Strategy",
|
||||
company: "RetailMax Corp",
|
||||
image: "https://images.unsplash.com/photo-1580489944761-15a19d654956?w=150&h=150&fit=crop&auto=format",
|
||||
rating: 5,
|
||||
text: "Our e-commerce platform's performance improved by 300% after WDI's optimization. Their technical expertise is outstanding.",
|
||||
projectType: "E-commerce Platform Enhancement"
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "James Thompson",
|
||||
position: "Chief Innovation Officer",
|
||||
company: "EduTech Pioneers",
|
||||
image: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&auto=format",
|
||||
rating: 5,
|
||||
text: "WDI's AI-powered learning platform has transformed how our students engage with content. Incredible innovation and execution.",
|
||||
projectType: "AI-Powered EdTech Platform"
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Lisa Park",
|
||||
position: "Operations Director",
|
||||
company: "LogiFlow Systems",
|
||||
image: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&auto=format",
|
||||
rating: 5,
|
||||
text: "The supply chain management system WDI built has streamlined our operations and reduced costs by 40%. Highly recommended.",
|
||||
projectType: "Supply Chain Management System"
|
||||
}
|
||||
];
|
||||
|
||||
export const CarouselTestimonials = () => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isAutoPlaying, setIsAutoPlaying] = useState(true);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Auto-play functionality
|
||||
useEffect(() => {
|
||||
if (isAutoPlaying) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCurrentIndex((prevIndex) =>
|
||||
prevIndex === testimonials.length - 1 ? 0 : prevIndex + 1
|
||||
);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [isAutoPlaying]);
|
||||
|
||||
const goToPrevious = () => {
|
||||
setIsAutoPlaying(false);
|
||||
setCurrentIndex(currentIndex === 0 ? testimonials.length - 1 : currentIndex - 1);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
setIsAutoPlaying(false);
|
||||
setCurrentIndex(currentIndex === testimonials.length - 1 ? 0 : currentIndex + 1);
|
||||
};
|
||||
|
||||
const goToSlide = (index: number) => {
|
||||
setIsAutoPlaying(false);
|
||||
setCurrentIndex(index);
|
||||
};
|
||||
|
||||
// Resume auto-play after user interaction
|
||||
useEffect(() => {
|
||||
if (!isAutoPlaying) {
|
||||
const resumeTimer = setTimeout(() => {
|
||||
setIsAutoPlaying(true);
|
||||
}, 10000); // Resume after 10 seconds
|
||||
|
||||
return () => clearTimeout(resumeTimer);
|
||||
}
|
||||
}, [isAutoPlaying]);
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-background">
|
||||
<div className="container mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-semibold text-foreground mb-6">
|
||||
What Our <span className="text-accent">Clients Say</span>
|
||||
</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
|
||||
Hear from industry leaders who have transformed their businesses with our innovative solutions.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative max-w-6xl mx-auto">
|
||||
{/* Main Testimonial Display */}
|
||||
<div className="relative overflow-hidden rounded-3xl">
|
||||
<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, ease: "easeInOut" }}
|
||||
>
|
||||
<Card className="bg-card/50 backdrop-blur-md border-white/10 shadow-2xl">
|
||||
<CardContent className="p-12">
|
||||
<div className="grid lg:grid-cols-3 gap-12 items-center">
|
||||
{/* Client Photo and Info */}
|
||||
<div className="lg:col-span-1 text-center lg:text-left">
|
||||
<div className="relative mb-8">
|
||||
<div className="w-32 h-32 mx-auto lg:mx-0 rounded-full overflow-hidden border-4 border-accent/20">
|
||||
<ImageWithFallback
|
||||
src={testimonials[currentIndex].image}
|
||||
alt={testimonials[currentIndex].name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/* Rating Stars */}
|
||||
<div className="flex justify-center lg:justify-start gap-1 mt-6">
|
||||
{[...Array(testimonials[currentIndex].rating)].map((_, i) => (
|
||||
<Star key={i} className="w-5 h-5 fill-accent text-accent" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-2xl font-semibold text-foreground">
|
||||
{testimonials[currentIndex].name}
|
||||
</h3>
|
||||
<p className="text-accent font-medium">
|
||||
{testimonials[currentIndex].position}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{testimonials[currentIndex].company}
|
||||
</p>
|
||||
<div className="pt-4">
|
||||
<span className="inline-block px-4 py-2 bg-accent/10 text-accent text-sm rounded-full border border-accent/20">
|
||||
{testimonials[currentIndex].projectType}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial Content */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="relative">
|
||||
{/* Quote Icon */}
|
||||
<div className="absolute -top-4 -left-4 text-6xl text-accent/20 font-serif">"</div>
|
||||
|
||||
<blockquote className="text-2xl lg:text-3xl text-foreground leading-relaxed font-medium pl-8">
|
||||
{testimonials[currentIndex].text}
|
||||
</blockquote>
|
||||
|
||||
{/* Clutch Logo */}
|
||||
<div className="flex items-center justify-end mt-8">
|
||||
<div className="text-sm text-muted-foreground mr-4">
|
||||
Verified Review on
|
||||
</div>
|
||||
<ImageWithFallback
|
||||
src={clutchLogo}
|
||||
alt="Clutch"
|
||||
className="h-8 w-auto opacity-70 hover:opacity-100 transition-opacity duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Navigation Controls */}
|
||||
<div className="flex items-center justify-center mt-12 gap-8">
|
||||
{/* Previous Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={goToPrevious}
|
||||
className="w-14 h-14 rounded-full border-white/20 hover:border-accent/50 hover:bg-accent/10 transition-all duration-300"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</Button>
|
||||
|
||||
{/* Dots Indicator */}
|
||||
<div className="flex gap-3">
|
||||
{testimonials.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => goToSlide(index)}
|
||||
className={`w-3 h-3 rounded-full transition-all duration-300 ${
|
||||
index === currentIndex
|
||||
? 'bg-accent scale-125'
|
||||
: 'bg-white/30 hover:bg-white/50'
|
||||
}`}
|
||||
aria-label={`Go to testimonial ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={goToNext}
|
||||
className="w-14 h-14 rounded-full border-white/20 hover:border-accent/50 hover:bg-accent/10 transition-all duration-300"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Auto-play Indicator */}
|
||||
<div className="text-center mt-6">
|
||||
<button
|
||||
onClick={() => setIsAutoPlaying(!isAutoPlaying)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors duration-300"
|
||||
>
|
||||
{isAutoPlaying ? '⏸ Pause Auto-play' : '▶ Resume Auto-play'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Social Proof */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mt-20 pt-16 border-t border-white/10"
|
||||
>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-4xl mx-auto">
|
||||
<div className="space-y-2">
|
||||
<div className="text-3xl font-bold text-accent">98%</div>
|
||||
<div className="text-sm text-muted-foreground">Client Satisfaction</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-3xl font-bold text-accent">150+</div>
|
||||
<div className="text-sm text-muted-foreground">Projects Delivered</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-3xl font-bold text-accent">5.0</div>
|
||||
<div className="text-sm text-muted-foreground">Average Rating</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-3xl font-bold text-accent">24/7</div>
|
||||
<div className="text-sm text-muted-foreground">Support Available</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
274
components/CaseStudyHighlight.tsx
Normal file
274
components/CaseStudyHighlight.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { ArrowRight, ExternalLink, TrendingUp, Users, Clock, Star } from "lucide-react";
|
||||
import { ImageWithFallback } from "./figma/ImageWithFallback";
|
||||
import { navigateTo } from "../App";
|
||||
|
||||
// High-quality project images
|
||||
const regroupImage = "https://images.unsplash.com/photo-1551650975-87deedd944c3?w=600&h=400&fit=crop&auto=format";
|
||||
const seezunImage = "https://images.unsplash.com/photo-1512941937669-90a1b58e7e9c?w=600&h=400&fit=crop&auto=format";
|
||||
const wokaAwardImage = "https://images.unsplash.com/photo-1517077304055-6e89abbf09b0?w=600&h=400&fit=crop&auto=format";
|
||||
|
||||
const caseStudies = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Regroup",
|
||||
subtitle: "Social Networking Revolution",
|
||||
description: "A comprehensive social platform that connects communities worldwide with advanced messaging, group management, and content sharing capabilities.",
|
||||
image: regroupImage,
|
||||
category: "Social Platform",
|
||||
client: "Regroup Technologies",
|
||||
duration: "8 months",
|
||||
teamSize: "12 developers",
|
||||
technologies: ["React Native", "Node.js", "MongoDB", "WebRTC", "AWS"],
|
||||
results: [
|
||||
{ metric: "User Engagement", value: "+240%" },
|
||||
{ metric: "Active Communities", value: "50K+" },
|
||||
{ metric: "Daily Messages", value: "2.5M+" }
|
||||
],
|
||||
awards: ["Best Social App 2023", "Innovation Award"],
|
||||
link: "/projects/regroup",
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Seezun",
|
||||
subtitle: "Next-Gen E-commerce Platform",
|
||||
description: "Revolutionary e-commerce solution with AI-powered recommendations, seamless checkout, and integrated inventory management for modern retailers.",
|
||||
image: seezunImage,
|
||||
category: "E-commerce",
|
||||
client: "Seezun Retail",
|
||||
duration: "6 months",
|
||||
teamSize: "8 developers",
|
||||
technologies: ["React", "Python", "PostgreSQL", "Redis", "Stripe"],
|
||||
results: [
|
||||
{ metric: "Conversion Rate", value: "+180%" },
|
||||
{ metric: "Page Load Speed", value: "2.1s" },
|
||||
{ metric: "Customer Satisfaction", value: "4.9/5" }
|
||||
],
|
||||
awards: ["E-commerce Excellence Award"],
|
||||
link: "/projects/seezun",
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Woka",
|
||||
subtitle: "Award-Winning Fitness App",
|
||||
description: "Comprehensive fitness and wellness platform with personalized workout plans, nutrition tracking, and community features that won multiple industry awards.",
|
||||
image: wokaAwardImage,
|
||||
category: "Health & Fitness",
|
||||
client: "Woka Wellness",
|
||||
duration: "10 months",
|
||||
teamSize: "15 developers",
|
||||
technologies: ["Flutter", "Firebase", "TensorFlow", "Apple HealthKit", "Google Fit"],
|
||||
results: [
|
||||
{ metric: "User Retention", value: "+320%" },
|
||||
{ metric: "Workout Completions", value: "1M+" },
|
||||
{ metric: "App Store Rating", value: "4.8/5" }
|
||||
],
|
||||
awards: ["App of the Year 2023", "Health Innovation Award", "User Choice Award"],
|
||||
link: "/projects/woka",
|
||||
featured: true
|
||||
}
|
||||
];
|
||||
|
||||
export const CaseStudyHighlight = () => {
|
||||
return (
|
||||
<section className="py-32 bg-background">
|
||||
<div className="container mx-auto px-6 lg:px-8">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<Badge variant="outline" className="mb-6 border-accent/20 text-accent">
|
||||
Featured Work
|
||||
</Badge>
|
||||
<h2 className="text-4xl lg:text-5xl font-semibold text-foreground mb-6">
|
||||
Success Stories That <span className="text-accent">Define Excellence</span>
|
||||
</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
|
||||
Explore our award-winning projects that have transformed businesses and delighted millions of users worldwide.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Featured Case Studies Grid */}
|
||||
<div className="grid lg:grid-cols-3 gap-8 mb-16">
|
||||
{caseStudies.map((study, index) => (
|
||||
<motion.div
|
||||
key={study.id}
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: index * 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{ y: -8, scale: 1.02 }}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => navigateTo(study.link)}
|
||||
>
|
||||
<Card className="bg-card/50 backdrop-blur-md border-white/10 hover:border-accent/30 transition-all duration-500 shadow-lg hover:shadow-2xl hover:shadow-accent/10 rounded-2xl overflow-hidden h-full">
|
||||
<CardContent className="p-0 flex flex-col h-full">
|
||||
{/* Image Header */}
|
||||
<div className="relative overflow-hidden">
|
||||
<ImageWithFallback
|
||||
src={study.image}
|
||||
alt={study.title}
|
||||
className="w-full h-64 object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||
|
||||
{/* Category Badge */}
|
||||
<div className="absolute top-4 left-4">
|
||||
<Badge className="bg-accent/90 text-white border-0">
|
||||
{study.category}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Awards */}
|
||||
{study.awards.length > 0 && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<div className="bg-amber-500/90 text-white px-3 py-1 rounded-full text-xs font-medium flex items-center gap-1">
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
Award Winner
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Title Overlay */}
|
||||
<div className="absolute bottom-4 left-4 right-4">
|
||||
<h3 className="text-2xl font-bold text-white mb-1">
|
||||
{study.title}
|
||||
</h3>
|
||||
<p className="text-white/80 text-sm">
|
||||
{study.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8 flex-1 flex flex-col">
|
||||
<p className="text-muted-foreground leading-relaxed mb-6 flex-1">
|
||||
{study.description}
|
||||
</p>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6 p-4 bg-accent/5 rounded-lg border border-accent/10">
|
||||
{study.results.slice(0, 3).map((result, idx) => (
|
||||
<div key={idx} className="text-center">
|
||||
<div className="text-lg font-bold text-accent">
|
||||
{result.value}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{result.metric}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Technologies */}
|
||||
<div className="mb-6">
|
||||
<p className="text-sm font-medium text-foreground mb-2">Technologies:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{study.technologies.slice(0, 3).map((tech) => (
|
||||
<Badge key={tech} variant="secondary" className="text-xs bg-muted/50">
|
||||
{tech}
|
||||
</Badge>
|
||||
))}
|
||||
{study.technologies.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs bg-muted/50">
|
||||
+{study.technologies.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Details */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="w-4 h-4" />
|
||||
{study.duration}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="w-4 h-4" />
|
||||
{study.teamSize}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Awards List */}
|
||||
{study.awards.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<p className="text-sm font-medium text-foreground mb-2">Awards:</p>
|
||||
<div className="space-y-1">
|
||||
{study.awards.slice(0, 2).map((award, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm text-amber-600">
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{award}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between text-accent hover:text-accent hover:bg-accent/10 group-hover:translate-x-1 transition-all duration-300 mt-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateTo(study.link);
|
||||
}}
|
||||
>
|
||||
<span>View Case Study</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Call-to-Action */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="bg-gradient-to-r from-accent/10 via-accent/5 to-accent/10 rounded-2xl p-8 border border-accent/20">
|
||||
<h3 className="text-2xl font-semibold text-foreground mb-4">
|
||||
Ready to Create Your Success Story?
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-2xl mx-auto">
|
||||
Join the ranks of industry leaders who have transformed their businesses with our innovative solutions.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-accent hover:bg-accent/90 text-white"
|
||||
onClick={() => navigateTo("/case-studies")}
|
||||
>
|
||||
View All Case Studies
|
||||
<ExternalLink className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => navigateTo("/start-a-project")}
|
||||
>
|
||||
Start Your Project
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
246
components/ClientLogos.tsx
Normal file
246
components/ClientLogos.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
|
||||
const companyLogos = [
|
||||
{ name: "TechFlow Solutions", logo: null, width: "140" },
|
||||
{ name: "DataSync Pro", logo: null, width: "120" },
|
||||
{ name: "CloudNova Systems", logo: null, width: "140" },
|
||||
{ name: "AMOZ", logo: null, width: "90" },
|
||||
{ name: "SimpliTend", logo: null, width: "120" },
|
||||
{ name: "Seezun", logo: null, width: "100" },
|
||||
{ name: "TradersCircuit", logo: null, width: "140" },
|
||||
{ name: "FreeU", logo: null, width: "90" },
|
||||
{ name: "Amble", logo: null, width: "100" },
|
||||
{ name: "Lean In World", logo: null, width: "130" },
|
||||
{ name: "WOKA", logo: null, width: "90" },
|
||||
{ name: "SSA", logo: null, width: "80" },
|
||||
{ name: "Dorf Ketal", logo: null, width: "120" },
|
||||
{ name: "Agromate", logo: null, width: "120" },
|
||||
{ name: "Regroup", logo: null, width: "110" },
|
||||
{ name: "CAD IT Solutions", logo: null, width: "150" },
|
||||
{ name: "Tanami Capital", logo: null, width: "140" },
|
||||
{ name: "SuperMoney Advisor", logo: null, width: "170" },
|
||||
{ name: "Prosperty Platform", logo: null, width: "160" },
|
||||
{ name: "Moving Cargo", logo: null, width: "130" },
|
||||
{ name: "GSF Mobile", logo: null, width: "120" },
|
||||
{ name: "Farm Feeder", logo: null, width: "120" },
|
||||
{ name: "Melbourne City Card", logo: null, width: "170" },
|
||||
{ name: "ByteForge Labs", logo: null, width: "130" },
|
||||
{ name: "CodeCraft Studio", logo: null, width: "140" },
|
||||
{ name: "DevStream Tech", logo: null, width: "130" },
|
||||
{ name: "NextGen Solutions", logo: null, width: "150" },
|
||||
{ name: "ProdPush Platform", logo: null, width: "140" },
|
||||
{ name: "ScaleUp Ventures", logo: null, width: "140" },
|
||||
{ name: "AlphaVision Labs", logo: null, width: "140" },
|
||||
{ name: "CloudSync Systems", logo: null, width: "140" },
|
||||
{ name: "TechNova Group", logo: null, width: "130" },
|
||||
{ name: "DataFlow Pro", logo: null, width: "120" },
|
||||
{ name: "InnovateLab", logo: null, width: "120" }
|
||||
];
|
||||
|
||||
const countryFlags = [
|
||||
{
|
||||
name: "United States",
|
||||
alt: "United States flag icon",
|
||||
flagSvg: (
|
||||
<svg viewBox="0 0 24 18" className="w-8 h-6">
|
||||
<rect width="24" height="18" fill="#B22234"/>
|
||||
<rect width="24" height="1.38" y="1.38" fill="white"/>
|
||||
<rect width="24" height="1.38" y="4.15" fill="white"/>
|
||||
<rect width="24" height="1.38" y="6.92" fill="white"/>
|
||||
<rect width="24" height="1.38" y="9.69" fill="white"/>
|
||||
<rect width="24" height="1.38" y="12.46" fill="white"/>
|
||||
<rect width="24" height="1.38" y="15.23" fill="white"/>
|
||||
<rect width="9.6" height="9.69" fill="#3C3B6E"/>
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: "United Kingdom",
|
||||
alt: "United Kingdom flag icon",
|
||||
flagSvg: (
|
||||
<svg viewBox="0 0 24 18" className="w-8 h-6">
|
||||
<rect width="24" height="18" fill="#012169"/>
|
||||
<path d="M0 0L24 18M24 0L0 18" stroke="white" strokeWidth="2"/>
|
||||
<path d="M0 0L24 18M24 0L0 18" stroke="#C8102E" strokeWidth="1"/>
|
||||
<path d="M12 0V18M0 9H24" stroke="white" strokeWidth="3"/>
|
||||
<path d="M12 0V18M0 9H24" stroke="#C8102E" strokeWidth="2"/>
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: "India",
|
||||
alt: "India flag icon",
|
||||
flagSvg: (
|
||||
<svg viewBox="0 0 24 18" className="w-8 h-6">
|
||||
<rect width="24" height="6" fill="#FF9933"/>
|
||||
<rect width="24" height="6" y="6" fill="white"/>
|
||||
<rect width="24" height="6" y="12" fill="#138808"/>
|
||||
<circle cx="12" cy="9" r="2" fill="none" stroke="#000080" strokeWidth="0.3"/>
|
||||
<g transform="translate(12,9)">
|
||||
{Array.from({length: 24}, (_, i) => (
|
||||
<line key={i} x1="0" y1="-1.5" x2="0" y2="-1.8" stroke="#000080" strokeWidth="0.1" transform={`rotate(${i * 15})`}/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: "Canada",
|
||||
alt: "Canada flag icon",
|
||||
flagSvg: (
|
||||
<svg viewBox="0 0 24 18" className="w-8 h-6">
|
||||
<rect width="6" height="18" fill="#FF0000"/>
|
||||
<rect width="12" height="18" x="6" fill="white"/>
|
||||
<rect width="6" height="18" x="18" fill="#FF0000"/>
|
||||
<path d="M12 4L13 7H16L13.5 9L14.5 12L12 10L9.5 12L10.5 9L8 7H11L12 4Z" fill="#FF0000"/>
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: "Australia",
|
||||
alt: "Australia flag icon",
|
||||
flagSvg: (
|
||||
<svg viewBox="0 0 24 18" className="w-8 h-6">
|
||||
<rect width="24" height="18" fill="#012169"/>
|
||||
<g transform="scale(0.5)">
|
||||
<rect width="24" height="9" fill="#012169"/>
|
||||
<path d="M0 0L24 18M24 0L0 18" stroke="white" strokeWidth="2"/>
|
||||
<path d="M0 0L24 18M24 0L0 18" stroke="#C8102E" strokeWidth="1"/>
|
||||
<path d="M12 0V18M0 9H24" stroke="white" strokeWidth="3"/>
|
||||
<path d="M12 0V18M0 9H24" stroke="#C8102E" strokeWidth="2"/>
|
||||
</g>
|
||||
<g fill="white">
|
||||
<circle cx="18" cy="6" r="0.5"/>
|
||||
<circle cx="20" cy="8" r="0.3"/>
|
||||
<circle cx="19" cy="10" r="0.4"/>
|
||||
<circle cx="21" cy="12" r="0.3"/>
|
||||
<circle cx="18" cy="14" r="0.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const ProjectImageCircles = () => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
className="flex justify-center items-center mb-12"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{countryFlags.map((flag, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.5 + (index * 0.1),
|
||||
type: "spring",
|
||||
stiffness: 200
|
||||
}}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{
|
||||
scale: 1.1,
|
||||
zIndex: 10,
|
||||
transition: { duration: 0.2 }
|
||||
}}
|
||||
className="relative w-16 h-16 cursor-pointer group"
|
||||
style={{
|
||||
marginLeft: index > 0 ? '-8px' : '0',
|
||||
zIndex: countryFlags.length - index
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 w-16 h-16 rounded-full bg-white/10 backdrop-blur-sm border-2 border-white/20 group-hover:border-accent/50 group-hover:bg-white/15 transition-all duration-300 shadow-lg group-hover:shadow-xl flex items-center justify-center overflow-hidden">
|
||||
<div
|
||||
className="w-10 h-8 flex items-center justify-center transform group-hover:scale-110 transition-transform duration-300"
|
||||
role="img"
|
||||
aria-label={flag.alt}
|
||||
>
|
||||
{flag.flagSvg}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtle glow effect */}
|
||||
<div className="absolute inset-0 w-16 h-16 rounded-full bg-gradient-to-br from-accent/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute -top-12 left-1/2 transform -translate-x-1/2 bg-[#0E0E0E] text-white text-xs px-3 py-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none shadow-lg border border-white/10 z-50">
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-accent">{flag.name}</div>
|
||||
</div>
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-3 border-r-3 border-t-3 border-transparent border-t-[#0E0E0E]"></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const LogoCard = ({ name, width }: { name: string; width: string }) => (
|
||||
<div
|
||||
className="flex items-center justify-center h-16 bg-white/8 rounded-xl border border-white/10 hover:scale-105 hover:bg-white/12 hover:border-accent/20 transition-all duration-300 px-6 shadow-lg backdrop-blur-sm group"
|
||||
style={{ minWidth: `${width}px` }}
|
||||
>
|
||||
<span className="text-white/80 font-medium text-sm text-center leading-tight group-hover:text-white/95 transition-colors duration-300">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MarqueeRow = ({ logos }: { logos: typeof companyLogos }) => (
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, -3200],
|
||||
}}
|
||||
transition={{
|
||||
x: {
|
||||
repeat: Infinity,
|
||||
repeatType: "loop",
|
||||
duration: 120,
|
||||
ease: "linear",
|
||||
},
|
||||
}}
|
||||
className="flex gap-6 items-center"
|
||||
style={{
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
{[...logos, ...logos].map((company, index) => (
|
||||
<LogoCard key={`${company.name}-${index}`} name={company.name} width={company.width} />
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
export const ClientLogos = () => {
|
||||
return (
|
||||
<section className="relative py-20 bg-[#121212] border-y border-white/5 overflow-hidden">
|
||||
<GridPattern strokeDasharray="4 2" />
|
||||
|
||||
<div className="relative z-10 container mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-8"
|
||||
>
|
||||
<h2 className="text-3xl lg:text-4xl font-semibold text-white mb-6">
|
||||
Trusted by Founders and CTOs Across 15+ Countries
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
{/* Project Image Circles */}
|
||||
<ProjectImageCircles />
|
||||
|
||||
{/* Company Logos Ticker */}
|
||||
<div className="overflow-hidden">
|
||||
<MarqueeRow logos={companyLogos} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
205
components/CookieConsent.tsx
Normal file
205
components/CookieConsent.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Cookie, Shield, Settings } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export const CookieConsent = () => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already made a choice
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
// Show banner after a short delay
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAcceptAll = () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
localStorage.setItem('cookiePreferences', JSON.stringify({
|
||||
necessary: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
functional: true
|
||||
}));
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
const handleDeclineAll = () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
localStorage.setItem('cookiePreferences', JSON.stringify({
|
||||
necessary: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false
|
||||
}));
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
const handleSavePreferences = () => {
|
||||
localStorage.setItem('cookieConsent', 'customized');
|
||||
// In a real app, you would save the specific preferences here
|
||||
setIsVisible(false);
|
||||
setShowSettings(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 bg-black border-t border-white/10 shadow-2xl"
|
||||
>
|
||||
<div className="container mx-auto px-4 lg:px-8">
|
||||
{!showSettings ? (
|
||||
// Main Cookie Consent Banner
|
||||
<div className="py-4 lg:py-6">
|
||||
<div className="flex flex-col lg:flex-row items-start lg:items-center gap-4 lg:gap-6">
|
||||
{/* Icon and Message */}
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="w-8 h-8 bg-[#E5195E]/20 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Cookie className="w-4 h-4 text-[#E5195E]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-semibold text-sm mb-1">
|
||||
We use cookies to enhance your experience
|
||||
</h3>
|
||||
<p className="text-[#CCCCCC] text-sm leading-relaxed">
|
||||
We use cookies to analyze site performance, deliver personalized content, and improve your browsing experience.
|
||||
By clicking "Accept All", you consent to our use of cookies.{' '}
|
||||
<a href="/privacy" className="text-[#E5195E] hover:text-[#E5195E]/80 underline">
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/20 text-white hover:bg-white/10 hover:border-[#E5195E]/50 hover:text-white transition-all duration-300"
|
||||
onClick={() => setShowSettings(true)}
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Customize
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/20 text-white hover:bg-white/10 hover:border-white/30 hover:text-white transition-all duration-300"
|
||||
onClick={handleDeclineAll}
|
||||
>
|
||||
Decline All
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#E5195E] hover:bg-[#E5195E]/90 text-white transition-all duration-300"
|
||||
onClick={handleAcceptAll}
|
||||
>
|
||||
Accept All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 lg:relative lg:top-auto lg:right-auto w-8 h-8 flex items-center justify-center text-[#CCCCCC] hover:text-white hover:bg-white/10 rounded-lg transition-all duration-300"
|
||||
aria-label="Close cookie banner"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Cookie Settings Panel
|
||||
<div className="py-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-8 h-8 bg-[#E5195E]/20 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-[#E5195E]" />
|
||||
</div>
|
||||
<h3 className="text-white font-semibold text-lg">Cookie Preferences</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Necessary Cookies */}
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-white font-medium">Necessary Cookies</h4>
|
||||
<div className="w-12 h-6 bg-[#E5195E] rounded-full flex items-center justify-end px-1">
|
||||
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[#CCCCCC] text-sm">
|
||||
These cookies are essential for the website to function properly. They cannot be disabled.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Analytics Cookies */}
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-white font-medium">Analytics Cookies</h4>
|
||||
<div className="w-12 h-6 bg-white/20 rounded-full flex items-center justify-start px-1">
|
||||
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[#CCCCCC] text-sm">
|
||||
Help us understand how visitors interact with our website by collecting anonymous information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Marketing Cookies */}
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-white font-medium">Marketing Cookies</h4>
|
||||
<div className="w-12 h-6 bg-white/20 rounded-full flex items-center justify-start px-1">
|
||||
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[#CCCCCC] text-sm">
|
||||
Used to track visitors across websites to display relevant advertisements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/20 text-white hover:bg-white/10 hover:border-white/30 hover:text-white transition-all duration-300"
|
||||
onClick={() => setShowSettings(false)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#E5195E] hover:bg-[#E5195E]/90 text-white transition-all duration-300"
|
||||
onClick={handleSavePreferences}
|
||||
>
|
||||
Save Preferences
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
192
components/CountryFlags.tsx
Normal file
192
components/CountryFlags.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const foundersAndCTOs = [
|
||||
{
|
||||
name: "SimpliTend",
|
||||
title: "HealthTech Platform",
|
||||
country: "India",
|
||||
code: "IN",
|
||||
flagEmoji: "🇮🇳",
|
||||
projectType: "Care Management"
|
||||
},
|
||||
{
|
||||
name: "Seezun",
|
||||
title: "Fashion Marketplace",
|
||||
country: "United Kingdom",
|
||||
code: "GB",
|
||||
flagEmoji: "🇬🇧",
|
||||
projectType: "P2P Platform"
|
||||
},
|
||||
{
|
||||
name: "AMOZ",
|
||||
title: "E-commerce Platform",
|
||||
country: "United States",
|
||||
code: "US",
|
||||
flagEmoji: "🇺🇸",
|
||||
projectType: "Digital Commerce"
|
||||
},
|
||||
{
|
||||
name: "TradersCircuit",
|
||||
title: "Trading Platform",
|
||||
country: "United Arab Emirates",
|
||||
code: "AE",
|
||||
flagEmoji: "🇦🇪",
|
||||
projectType: "FinTech"
|
||||
},
|
||||
{
|
||||
name: "FreeU",
|
||||
title: "Social Platform",
|
||||
country: "Australia",
|
||||
code: "AU",
|
||||
flagEmoji: "🇦🇺",
|
||||
projectType: "Community"
|
||||
},
|
||||
{
|
||||
name: "Dorf Ketal",
|
||||
title: "Manufacturing Tech",
|
||||
country: "Germany",
|
||||
code: "DE",
|
||||
flagEmoji: "🇩🇪",
|
||||
projectType: "Industrial IoT"
|
||||
},
|
||||
{
|
||||
name: "WOKA",
|
||||
title: "Learning Platform",
|
||||
country: "Singapore",
|
||||
code: "SG",
|
||||
flagEmoji: "🇸🇬",
|
||||
projectType: "EdTech"
|
||||
},
|
||||
{
|
||||
name: "Regroup",
|
||||
title: "Sports Networking",
|
||||
country: "Canada",
|
||||
code: "CA",
|
||||
flagEmoji: "🇨🇦",
|
||||
projectType: "Social Sports"
|
||||
},
|
||||
{
|
||||
name: "Tanami Capital",
|
||||
title: "Wealth Management",
|
||||
country: "Brazil",
|
||||
code: "BR",
|
||||
flagEmoji: "🇧🇷",
|
||||
projectType: "FinTech"
|
||||
},
|
||||
{
|
||||
name: "SSA",
|
||||
title: "Networking Platform",
|
||||
country: "Japan",
|
||||
code: "JP",
|
||||
flagEmoji: "🇯🇵",
|
||||
projectType: "Professional Network"
|
||||
},
|
||||
{
|
||||
name: "Amble",
|
||||
title: "Travel Platform",
|
||||
country: "France",
|
||||
code: "FR",
|
||||
flagEmoji: "🇫🇷",
|
||||
projectType: "Travel Tech"
|
||||
},
|
||||
{
|
||||
name: "Agromate",
|
||||
title: "AgriTech Solution",
|
||||
country: "Netherlands",
|
||||
code: "NL",
|
||||
flagEmoji: "🇳🇱",
|
||||
projectType: "Agriculture"
|
||||
},
|
||||
{
|
||||
name: "Moving Cargo",
|
||||
title: "Logistics Platform",
|
||||
country: "Sweden",
|
||||
code: "SE",
|
||||
flagEmoji: "🇸🇪",
|
||||
projectType: "Supply Chain"
|
||||
},
|
||||
{
|
||||
name: "Farm Feeder",
|
||||
title: "Agricultural Tech",
|
||||
country: "New Zealand",
|
||||
code: "NZ",
|
||||
flagEmoji: "🇳🇿",
|
||||
projectType: "AgriTech"
|
||||
},
|
||||
{
|
||||
name: "Melbourne City Card",
|
||||
title: "City Services Platform",
|
||||
country: "South Korea",
|
||||
code: "KR",
|
||||
flagEmoji: "🇰🇷",
|
||||
projectType: "Civic Tech"
|
||||
}
|
||||
];
|
||||
|
||||
export const CountryFlags = () => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
className="mb-12"
|
||||
>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6 max-w-7xl mx-auto">
|
||||
{foundersAndCTOs.map((project, index) => (
|
||||
<motion.div
|
||||
key={project.code + index}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.5 + (index * 0.1),
|
||||
type: "spring",
|
||||
stiffness: 200
|
||||
}}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
transition: { duration: 0.2 }
|
||||
}}
|
||||
className="group cursor-pointer relative"
|
||||
>
|
||||
<div className="text-center">
|
||||
{/* Flag Icon */}
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-14 h-14 rounded-full bg-white/10 backdrop-blur-sm border border-white/20 flex items-center justify-center group-hover:bg-white/20 group-hover:border-accent/30 transition-all duration-300 shadow-lg group-hover:shadow-xl">
|
||||
<span className="text-2xl" role="img" aria-label={`${project.country} flag`}>
|
||||
{project.flagEmoji}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Badge */}
|
||||
<div className="bg-white/8 rounded-lg border border-white/10 px-3 py-2.5 group-hover:bg-white/12 group-hover:border-accent/30 transition-all duration-300 shadow-sm backdrop-blur-sm min-h-[60px] flex flex-col justify-center">
|
||||
<div className="text-white/90 font-medium text-sm leading-tight mb-1 group-hover:text-white transition-colors duration-300">
|
||||
{project.name}
|
||||
</div>
|
||||
<div className="text-white/60 text-xs leading-tight">
|
||||
{project.projectType}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Tooltip */}
|
||||
<div className="absolute -top-24 left-1/2 transform -translate-x-1/2 bg-[#0E0E0E] text-white text-xs px-4 py-3 rounded-xl opacity-0 group-hover:opacity-100 transition-all duration-300 whitespace-nowrap pointer-events-none shadow-xl border border-white/20 z-50 backdrop-blur-md">
|
||||
<div className="text-center">
|
||||
<div className="font-semibold text-accent mb-1">{project.name}</div>
|
||||
<div className="text-white/80 mb-1">{project.title}</div>
|
||||
<div className="text-white/60 flex items-center gap-1 justify-center">
|
||||
<span>{project.flagEmoji}</span>
|
||||
<span>{project.country}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-[#0E0E0E]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
160
components/CustomReCaptcha.tsx
Normal file
160
components/CustomReCaptcha.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
interface CustomReCaptchaProps {
|
||||
siteKey: string;
|
||||
onVerify: (token: string) => void;
|
||||
onExpired?: () => void;
|
||||
onError?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ReCaptchaRef {
|
||||
reset: () => void;
|
||||
execute: () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
grecaptcha: any;
|
||||
}
|
||||
}
|
||||
|
||||
const CustomReCaptcha = forwardRef<ReCaptchaRef, CustomReCaptchaProps>(({
|
||||
siteKey,
|
||||
onVerify,
|
||||
onExpired,
|
||||
onError,
|
||||
className = ""
|
||||
}, ref) => {
|
||||
const captchaRef = useRef<HTMLDivElement>(null);
|
||||
const widgetId = useRef<number | null>(null);
|
||||
const isLoadedRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset: () => {
|
||||
if (window.grecaptcha && widgetId.current !== null) {
|
||||
window.grecaptcha.reset(widgetId.current);
|
||||
}
|
||||
},
|
||||
execute: () => {
|
||||
if (window.grecaptcha && widgetId.current !== null) {
|
||||
window.grecaptcha.execute(widgetId.current);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const loadReCaptcha = () => {
|
||||
if (window.grecaptcha) {
|
||||
renderReCaptcha();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load reCAPTCHA script
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.google.com/recaptcha/api.js?onload=onReCaptchaLoad&render=explicit';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
|
||||
// Set up callback for when script loads
|
||||
(window as any).onReCaptchaLoad = () => {
|
||||
renderReCaptcha();
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
|
||||
const renderReCaptcha = () => {
|
||||
if (!captchaRef.current || isLoadedRef.current) return;
|
||||
|
||||
try {
|
||||
widgetId.current = window.grecaptcha.render(captchaRef.current, {
|
||||
sitekey: siteKey,
|
||||
callback: onVerify,
|
||||
'expired-callback': onExpired,
|
||||
'error-callback': onError,
|
||||
theme: 'dark',
|
||||
size: 'normal'
|
||||
});
|
||||
isLoadedRef.current = true;
|
||||
} catch (error) {
|
||||
console.error('Error rendering reCAPTCHA:', error);
|
||||
if (onError) onError();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadReCaptcha();
|
||||
|
||||
return () => {
|
||||
// Cleanup
|
||||
if (window.grecaptcha && widgetId.current !== null) {
|
||||
try {
|
||||
window.grecaptcha.reset(widgetId.current);
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Add styles to document head instead of using styled-jsx
|
||||
useEffect(() => {
|
||||
const styleId = 'custom-recaptcha-styles';
|
||||
|
||||
// Check if styles are already added
|
||||
if (document.getElementById(styleId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.id = styleId;
|
||||
styleElement.textContent = `
|
||||
.grecaptcha-badge {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
iframe[src*="recaptcha"] {
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.g-recaptcha {
|
||||
transform: scale(1);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.g-recaptcha > div {
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
// Cleanup function to remove styles when component unmounts
|
||||
return () => {
|
||||
const existingStyle = document.getElementById(styleId);
|
||||
if (existingStyle) {
|
||||
document.head.removeChild(existingStyle);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`flex justify-center ${className}`}>
|
||||
<div
|
||||
className="bg-gray-800/30 border border-gray-600/50 rounded-xl p-6 shadow-lg backdrop-blur-sm"
|
||||
style={{
|
||||
'--recaptcha-border-radius': '12px'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div ref={captchaRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CustomReCaptcha.displayName = 'CustomReCaptcha';
|
||||
|
||||
export default CustomReCaptcha;
|
||||
79
components/FAQSection.tsx
Normal file
79
components/FAQSection.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
interface FAQ {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
interface FAQSectionProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
faqs: FAQ[];
|
||||
}
|
||||
|
||||
export const FAQSection: React.FC<FAQSectionProps> = ({
|
||||
title = "Frequently Asked Questions",
|
||||
subtitle,
|
||||
faqs
|
||||
}) => {
|
||||
return (
|
||||
<section className="py-20 bg-black">
|
||||
<div className="container mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-semibold text-white mb-6">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-lg text-gray-400 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="max-w-4xl mx-auto"
|
||||
>
|
||||
<Accordion type="single" collapsible className="w-full space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<AccordionItem
|
||||
value={`item-${index}`}
|
||||
className="border-none bg-slate-800/40 rounded-xl overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger
|
||||
className="text-left text-white hover:text-white hover:no-underline px-6 py-6 text-lg font-medium [&[data-state=open]>svg]:rotate-180"
|
||||
>
|
||||
<span className="flex-1 text-left">{faq.question}</span>
|
||||
<ChevronDown className="h-5 w-5 shrink-0 text-white transition-transform duration-200" />
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-gray-300 px-6 pb-6 pt-0 text-base leading-relaxed">
|
||||
{faq.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</motion.div>
|
||||
))}
|
||||
</Accordion>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
497
components/FeaturedCaseStudies.tsx
Normal file
497
components/FeaturedCaseStudies.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
import { ImageWithFallback } from "./figma/ImageWithFallback";
|
||||
import { navigateTo } from "../App";
|
||||
import {
|
||||
ArrowRight,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Zap,
|
||||
Eye,
|
||||
ShoppingCart,
|
||||
Heart,
|
||||
Star,
|
||||
Clock,
|
||||
Target,
|
||||
Smartphone,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Network,
|
||||
Search,
|
||||
Calendar,
|
||||
PlayCircle,
|
||||
PartyPopper,
|
||||
PieChart
|
||||
} from "lucide-react";
|
||||
|
||||
const FeaturedCaseStudies = () => {
|
||||
const caseStudies = [
|
||||
{
|
||||
id: 1,
|
||||
title: "SimplyTend",
|
||||
client: "Simply Tend",
|
||||
description: "SimpliTend is a mobile-first care management platform that connects patients and caregivers through real-time alerts, scheduling, and safety tools—delivered via dual apps and an admin dashboard.",
|
||||
keyAchievement: {
|
||||
number: "95%",
|
||||
metric: "Care Coordination Efficiency",
|
||||
icon: Heart
|
||||
},
|
||||
visual: "https://images.unsplash.com/photo-1559757148-5c350d0d3c56?w=600&h=400&fit=crop&auto=format",
|
||||
tags: ["Mobile App", "Care Management", "Real-Time Alerts", "Scheduling"],
|
||||
gradient: "from-blue-500/20 to-cyan-500/20",
|
||||
accentColor: "blue",
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Seezun",
|
||||
client: "Seezun",
|
||||
description: "Seezun is a trend-driven P2P fashion marketplace enabling users to rent, buy, sell, or lend South Asian ethnicwear via mobile and web platforms.",
|
||||
keyAchievement: {
|
||||
number: "85%",
|
||||
metric: "Brand Recognition",
|
||||
icon: TrendingUp
|
||||
},
|
||||
visual: "https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=600&h=400&fit=crop&auto=format",
|
||||
tags: ["Marketplace", "P2P", "Fashion", "Mobile & Web"],
|
||||
gradient: "from-purple-500/20 to-pink-500/20",
|
||||
accentColor: "purple",
|
||||
featured: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "WOKA",
|
||||
client: "WOKA Creations Pvt. Ltd",
|
||||
description: "WDI transformed WOKA's hybrid app into a high-performance native Android and iOS platform featuring seamless streaming, deep analytics, and robust 120-hour monthly support.",
|
||||
keyAchievement: {
|
||||
number: "300%",
|
||||
metric: "User Retention",
|
||||
icon: Users
|
||||
},
|
||||
visual: "https://images.unsplash.com/photo-1503676260728-1c00da094a0b?w=600&h=400&fit=crop&auto=format",
|
||||
tags: ["Native App", "Streaming", "Analytics", "Support"],
|
||||
gradient: "from-green-500/20 to-emerald-500/20",
|
||||
accentColor: "green",
|
||||
featured: false
|
||||
}
|
||||
];
|
||||
|
||||
const moreSuccessStories = [
|
||||
{
|
||||
id: 4,
|
||||
title: "TradersCircuit",
|
||||
client: "TradersCircuit",
|
||||
description: "TradersCircuit empowers India's millennials and Gen Z with smarter investments through seamless investment experience and ultra-personalized financial planning.",
|
||||
keyAchievement: {
|
||||
number: "300%",
|
||||
metric: "User Growth",
|
||||
icon: TrendingUp
|
||||
},
|
||||
visual: "https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=600&h=400&fit=crop&auto=format",
|
||||
tags: ["FinTech", "Trading Platform", "Indian Market", "Mobile App"],
|
||||
gradient: "from-green-500/20 to-emerald-500/20",
|
||||
accentColor: "green",
|
||||
featured: false
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "RanOutOf",
|
||||
client: "Global Ease Solutions",
|
||||
description: "WDI developed a smart grocery planning app with barcode scanning, voice commands, reminders, and a web-based admin CMS for Global Ease Solutions.",
|
||||
keyAchievement: {
|
||||
number: "75%",
|
||||
metric: "Shopping Efficiency",
|
||||
icon: ShoppingCart
|
||||
},
|
||||
visual: "https://images.unsplash.com/photo-1542838132-92c53300491e?w=600&h=400&fit=crop&auto=format",
|
||||
tags: ["Mobile App", "Barcode Scanning", "Voice AI", "Grocery Tech"],
|
||||
gradient: "from-green-500/20 to-emerald-500/20",
|
||||
accentColor: "green",
|
||||
featured: false
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Prosperty",
|
||||
client: "Prosperty Ltd",
|
||||
description: "Break the barrier of real estate investing. With Prosperty, you can invest in portions of properties, making portfolio diversification smarter and more accessible.",
|
||||
keyAchievement: {
|
||||
number: "300%",
|
||||
metric: "Portfolio Options",
|
||||
icon: PieChart
|
||||
},
|
||||
visual: "https://images.unsplash.com/photo-1560518883-ce09059eeffa?w=600&h=400&fit=crop&auto=format",
|
||||
tags: ["Real Estate", "Investment", "FinTech", "Portfolio"],
|
||||
gradient: "from-blue-500/20 to-cyan-500/20",
|
||||
accentColor: "blue",
|
||||
featured: false
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "GoodTimes",
|
||||
client: "GoodTimes Ltd",
|
||||
description: "From casual hangouts to special celebrations, Good Times makes browsing and booking a breeze, so you never miss out.",
|
||||
keyAchievement: {
|
||||
number: "250%",
|
||||
metric: "Event Discovery",
|
||||
icon: PartyPopper
|
||||
},
|
||||
visual: "https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=600&h=400&fit=crop&auto=format",
|
||||
tags: ["Events", "Booking", "Lifestyle", "Mobile App"],
|
||||
gradient: "from-purple-500/20 to-pink-500/20",
|
||||
accentColor: "purple",
|
||||
featured: false
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-20 bg-black">
|
||||
<div className="container mx-auto px-6 lg:px-8">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="inline-block p-[2px] rounded-full bg-gradient-to-r from-accent via-blue-500 to-purple-500">
|
||||
<div className="bg-black rounded-full px-6 py-3 flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-accent" />
|
||||
<span className="text-white text-base font-medium">Featured Work</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h2 className="text-4xl lg:text-5xl font-semibold text-white mb-6">
|
||||
Featured Success Stories
|
||||
</h2>
|
||||
<p className="text-xl text-gray-300 max-w-3xl mx-auto leading-relaxed">
|
||||
Discover how we've helped companies across industries achieve remarkable results with our innovative development solutions.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Case Studies Grid - Consistent Dimensions */}
|
||||
<div className="grid lg:grid-cols-3 gap-8 items-stretch">
|
||||
{caseStudies.map((study, index) => {
|
||||
const AchievementIcon = study.keyAchievement.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={study.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="group h-full"
|
||||
>
|
||||
<Card
|
||||
className="bg-gray-900/50 backdrop-blur-md border-gray-800 hover:border-accent/30 transition-all duration-500 shadow-lg hover:shadow-2xl rounded-2xl overflow-hidden h-full group-hover:scale-[1.02] transform flex flex-col cursor-pointer"
|
||||
onClick={() => {
|
||||
if (study.title === 'Seezun') {
|
||||
navigateTo('/projects/seezun');
|
||||
} else if (study.title === 'WOKA') {
|
||||
navigateTo('/projects/woka');
|
||||
} else if (study.title === 'Tanami') {
|
||||
navigateTo('/projects/tanami');
|
||||
} else {
|
||||
navigateTo('/case-studies');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-0 flex flex-col h-full min-h-[600px]">
|
||||
{/* Visual Section - Fixed Height */}
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="relative h-64 w-full">
|
||||
<ImageWithFallback
|
||||
src={study.visual}
|
||||
alt={study.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
|
||||
{/* Overlay with gradient */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-t ${study.gradient} opacity-20 group-hover:opacity-40 transition-opacity duration-500`} />
|
||||
|
||||
{/* Featured Badge */}
|
||||
{study.featured && (
|
||||
<div className="absolute top-4 left-4">
|
||||
<Badge className="bg-accent text-white shadow-lg">
|
||||
<Star className="w-3 h-3 mr-1" />
|
||||
Featured
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key Achievement - Overlaid on Visual */}
|
||||
<div className="absolute bottom-4 left-4 right-4">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="bg-black/80 backdrop-blur-md rounded-xl p-4 border border-white/10"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg bg-gradient-to-r ${
|
||||
study.accentColor === 'blue' ? 'from-blue-500 to-cyan-500' :
|
||||
study.accentColor === 'green' ? 'from-green-500 to-emerald-500' :
|
||||
study.accentColor === 'purple' ? 'from-purple-500 to-pink-500' :
|
||||
study.accentColor === 'cyan' ? 'from-cyan-500 to-blue-500' :
|
||||
study.accentColor === 'orange' ? 'from-orange-500 to-red-500' :
|
||||
'from-emerald-500 to-teal-500'
|
||||
} flex items-center justify-center flex-shrink-0`}>
|
||||
<AchievementIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-2xl font-bold text-white">{study.keyAchievement.number}</div>
|
||||
<div className="text-sm text-gray-300 leading-tight">{study.keyAchievement.metric}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Section - Flexible Height with Consistent Spacing */}
|
||||
<div className="p-6 flex-1 flex flex-col justify-between min-h-[336px]">
|
||||
<div className="flex-1">
|
||||
{/* Project Title - Consistent Height */}
|
||||
<div className="mb-4 min-h-[60px] flex items-start">
|
||||
<h3 className="text-xl font-semibold text-white leading-tight group-hover:text-accent transition-colors duration-300 line-clamp-2">
|
||||
{study.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Client & Description - Consistent Height */}
|
||||
<div className="mb-6 min-h-[100px]">
|
||||
<div className="text-accent font-medium text-sm mb-2">{study.client}</div>
|
||||
<p className="text-gray-300 text-sm leading-relaxed line-clamp-4">
|
||||
{study.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags - Consistent Height */}
|
||||
<div className="mb-6 min-h-[32px]">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{study.tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="text-xs bg-gray-800/50 text-gray-300 border-gray-700 hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Button - Fixed at Bottom */}
|
||||
<div className="mt-auto">
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-accent to-accent/80 hover:from-accent/90 hover:to-accent/70 text-white font-semibold py-3 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 group h-12"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (study.title === 'Seezun') {
|
||||
navigateTo('/projects/seezun');
|
||||
} else if (study.title === 'WOKA') {
|
||||
navigateTo('/projects/woka');
|
||||
} else if (study.title === 'Tanami') {
|
||||
navigateTo('/projects/tanami');
|
||||
} else {
|
||||
navigateTo('/case-studies');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>View Full Case Study</span>
|
||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform duration-300" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* More Success Stories Section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
className="mt-20 mb-16"
|
||||
>
|
||||
<h3 className="text-3xl lg:text-4xl font-semibold text-white mb-12 text-center">
|
||||
More Success Stories
|
||||
</h3>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8 items-stretch">
|
||||
{moreSuccessStories.map((story, index) => {
|
||||
const AchievementIcon = story.keyAchievement.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={story.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="group h-full"
|
||||
>
|
||||
<Card
|
||||
className="bg-gray-900/50 backdrop-blur-md border-gray-800 hover:border-accent/30 transition-all duration-500 shadow-lg hover:shadow-2xl rounded-2xl overflow-hidden h-full group-hover:scale-[1.02] transform flex flex-col cursor-pointer"
|
||||
onClick={() => {
|
||||
if (story.title === 'TradersCircuit') {
|
||||
navigateTo('/projects/traderscircuit');
|
||||
} else if (story.title === 'GoodTimes') {
|
||||
navigateTo('/projects/goodtimes');
|
||||
} else if (story.title === 'Prosperty') {
|
||||
navigateTo('/projects/prosperty');
|
||||
} else if (story.title === 'RanOutOf') {
|
||||
navigateTo('/projects/ranoutof');
|
||||
} else {
|
||||
navigateTo('/case-studies');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-0 flex flex-col h-full min-h-[600px]">
|
||||
{/* Visual Section - Fixed Height */}
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="relative h-64 w-full">
|
||||
<ImageWithFallback
|
||||
src={story.visual}
|
||||
alt={`${story.title} - ${story.client}`}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
|
||||
{/* Overlay with gradient */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-t ${story.gradient} opacity-20 group-hover:opacity-40 transition-opacity duration-500`} />
|
||||
|
||||
{/* Key Achievement - Overlaid on Visual */}
|
||||
<div className="absolute bottom-4 left-4 right-4">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="bg-black/80 backdrop-blur-md rounded-xl p-4 border border-white/10"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg bg-gradient-to-r ${
|
||||
story.accentColor === 'blue' ? 'from-blue-500 to-cyan-500' :
|
||||
story.accentColor === 'green' ? 'from-green-500 to-emerald-500' :
|
||||
story.accentColor === 'purple' ? 'from-purple-500 to-pink-500' :
|
||||
story.accentColor === 'cyan' ? 'from-cyan-500 to-blue-500' :
|
||||
story.accentColor === 'orange' ? 'from-orange-500 to-red-500' :
|
||||
'from-emerald-500 to-teal-500'
|
||||
} flex items-center justify-center flex-shrink-0`}>
|
||||
<AchievementIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-2xl font-bold text-white">{story.keyAchievement.number}</div>
|
||||
<div className="text-sm text-gray-300 leading-tight">{story.keyAchievement.metric}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Section - Flexible Height with Consistent Spacing */}
|
||||
<div className="p-6 flex-1 flex flex-col justify-between min-h-[336px]">
|
||||
<div className="flex-1">
|
||||
{/* Project Title - Consistent Height */}
|
||||
<div className="mb-4 min-h-[60px] flex items-start">
|
||||
<h4 className="text-xl font-semibold text-white leading-tight group-hover:text-accent transition-colors duration-300 line-clamp-2">
|
||||
{story.title}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* Client & Description - Consistent Height */}
|
||||
<div className="mb-6 min-h-[100px]">
|
||||
<div className="text-accent font-medium text-sm mb-2">{story.client}</div>
|
||||
<p className="text-gray-300 text-sm leading-relaxed line-clamp-4">
|
||||
{story.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags - Consistent Height */}
|
||||
<div className="mb-6 min-h-[32px]">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{story.tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="text-xs bg-gray-800/50 text-gray-300 border-gray-700 hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Button - Fixed at Bottom */}
|
||||
<div className="mt-auto">
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-accent to-accent/80 hover:from-accent/90 hover:to-accent/70 text-white font-semibold py-3 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 group h-12"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (story.title === 'TradersCircuit') {
|
||||
navigateTo('/projects/traderscircuit');
|
||||
} else if (story.title === 'GoodTimes') {
|
||||
navigateTo('/projects/goodtimes');
|
||||
} else if (story.title === 'Prosperty') {
|
||||
navigateTo('/projects/prosperty');
|
||||
} else if (story.title === 'RanOutOf') {
|
||||
navigateTo('/projects/ranoutof');
|
||||
} else {
|
||||
navigateTo('/case-studies');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>View Full Case Study</span>
|
||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform duration-300" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mt-16"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-800 hover:text-white hover:border-accent/50 transition-all duration-300"
|
||||
>
|
||||
<Eye className="w-5 h-5 mr-2" />
|
||||
View All Case Studies
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturedCaseStudies;
|
||||
426
components/Footer.tsx
Normal file
426
components/Footer.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Linkedin,
|
||||
Twitter,
|
||||
Github,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import BlackLogo14 from "../imports/BlackLogo14";
|
||||
import { navigateTo } from "../App";
|
||||
import { useState } from "react";
|
||||
|
||||
const footerNavigation = {
|
||||
Explore: [
|
||||
{ label: "Home", url: "/home" },
|
||||
{ label: "Services", url: "/services" },
|
||||
{ label: "Solutions", url: "/solutions" },
|
||||
{ label: "Industries", url: "/industries" },
|
||||
{ label: "Company", url: "/company" },
|
||||
{ label: "Contact", url: "/contact" },
|
||||
],
|
||||
Resources: [
|
||||
{ label: "Articles", url: "/resources/blog" },
|
||||
{ label: "Case Studies", url: "/case-studies" },
|
||||
{
|
||||
label: "Client Testimonials",
|
||||
url: "/resources/client-testimonials",
|
||||
},
|
||||
{
|
||||
label: "Whitepapers & Insights",
|
||||
url: "/resources/whitepapers-insights",
|
||||
},
|
||||
{ label: "FAQ", url: "/resources/faqs" },
|
||||
],
|
||||
Services: [
|
||||
{
|
||||
label: "Mobile App Development",
|
||||
url: "/services/mobile-app-development",
|
||||
},
|
||||
{
|
||||
label: "Web & Cloud Solutions",
|
||||
url: "/services/web-cloud-solutions",
|
||||
},
|
||||
{
|
||||
label: "Software Engineering",
|
||||
url: "/services/software-engineering",
|
||||
},
|
||||
{
|
||||
label: "Design & Experience",
|
||||
url: "/services/design-experience",
|
||||
},
|
||||
],
|
||||
"AI & ML": [
|
||||
{
|
||||
label: "Artificial Intelligence Services",
|
||||
url: "/ai/artificial-intelligence-services",
|
||||
},
|
||||
{
|
||||
label: "Machine Learning Solutions",
|
||||
url: "/ai/machine-learning-solutions",
|
||||
},
|
||||
],
|
||||
Solutions: [
|
||||
{
|
||||
label: "Digital Product Development",
|
||||
url: "/digital-product-development",
|
||||
},
|
||||
{
|
||||
label: "MVP & Startup Launch Packages",
|
||||
url: "/mvp-startup-launch",
|
||||
},
|
||||
{
|
||||
label: "Legacy System Rebuilds",
|
||||
url: "/legacy-system-rebuilds",
|
||||
},
|
||||
{
|
||||
label: "Dedicated Development Centers",
|
||||
url: "/dedicated-development-centers",
|
||||
},
|
||||
{
|
||||
label: "Business Process Automation",
|
||||
url: "/business-process-automation",
|
||||
},
|
||||
{
|
||||
label: "Compliance-Ready Systems",
|
||||
url: "/compliance-ready-systems",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const socialLinks = [
|
||||
{
|
||||
name: "LinkedIn",
|
||||
icon: Linkedin,
|
||||
url: "https://linkedin.com/company/wdi",
|
||||
},
|
||||
{
|
||||
name: "Twitter",
|
||||
icon: Twitter,
|
||||
url: "https://twitter.com/wdi_dev",
|
||||
},
|
||||
{
|
||||
name: "GitHub",
|
||||
icon: Github,
|
||||
url: "https://github.com/wdi",
|
||||
},
|
||||
{
|
||||
name: "YouTube",
|
||||
icon: Youtube,
|
||||
url: "https://youtube.com/wdi",
|
||||
},
|
||||
];
|
||||
|
||||
const contactInfo = [
|
||||
{
|
||||
icon: Mail,
|
||||
label: "ideas@wdipl.com",
|
||||
url: "mailto:ideas@wdipl.com",
|
||||
},
|
||||
{
|
||||
icon: Phone,
|
||||
label: "(+91) 7700900039",
|
||||
url: "tel:+917700900039",
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
label:
|
||||
"614, Palm Spring Centre, Link Road, Malad (West), Mumbai - 400064. India.",
|
||||
url: "#",
|
||||
},
|
||||
];
|
||||
|
||||
const FooterSection = ({
|
||||
title,
|
||||
links,
|
||||
delay = 0,
|
||||
}: {
|
||||
title: string;
|
||||
links: Array<{ label: string; url: string }>;
|
||||
delay?: number;
|
||||
}) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay }}
|
||||
viewport={{ once: true }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<h4 className="font-semibold text-white text-lg">
|
||||
{title}
|
||||
</h4>
|
||||
<ul className="space-y-3">
|
||||
{links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigateTo(link.url);
|
||||
}}
|
||||
className="text-[#CCCCCC] hover:text-white transition-colors duration-200 text-sm block py-1 hover:translate-x-1 transform cursor-pointer"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// Newsletter subscription component
|
||||
const NewsletterSection = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubscribe = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
setIsSubscribed(true);
|
||||
setIsSubmitting(false);
|
||||
setEmail("");
|
||||
|
||||
// Reset success message after 3 seconds
|
||||
setTimeout(() => setIsSubscribed(false), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
className="border-t border-white/10"
|
||||
>
|
||||
<div className="container mx-auto px-6 lg:px-8 py-16">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h3 className="text-2xl lg:text-3xl font-semibold text-white mb-4">
|
||||
Never Miss an Update
|
||||
</h3>
|
||||
<p className="text-[#CCCCCC] text-lg mb-8 max-w-2xl mx-auto">
|
||||
Get the latest insights on digital product
|
||||
development, AI trends, and startup success stories
|
||||
delivered to your inbox.
|
||||
</p>
|
||||
|
||||
{isSubscribed ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-green-500/10 border border-green-500/20 rounded-lg p-6 max-w-md mx-auto"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 text-green-400">
|
||||
<Mail className="w-5 h-5" />
|
||||
<span className="font-medium">
|
||||
Successfully subscribed!
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-green-300 text-sm mt-2">
|
||||
Welcome to our community of innovators.
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubscribe}
|
||||
className="max-w-md mx-auto"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="flex-1 bg-white/5 border-white/10 text-white placeholder:text-[#CCCCCC] focus:border-[#E5195E] focus:ring-[#E5195E]/20"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-[#E5195E] hover:bg-[#E5195E]/90 text-white px-6 shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting
|
||||
? "Subscribing..."
|
||||
: "Subscribe"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[#CCCCCC] text-xs mt-3">
|
||||
No spam, unsubscribe at any time. We respect
|
||||
your privacy.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Footer = () => {
|
||||
return (
|
||||
<footer className="relative bg-[#0E0E0E] border-t border-white/10 overflow-hidden">
|
||||
<GridPattern strokeDasharray="4 2" />
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Main Footer Content */}
|
||||
<div className="container mx-auto px-6 lg:px-8 py-16">
|
||||
<div className="grid lg:grid-cols-7 gap-12">
|
||||
{/* Company Info */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="lg:col-span-2 space-y-6"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12">
|
||||
<BlackLogo14 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[#CCCCCC] leading-relaxed max-w-md">
|
||||
Web Development Institute - Transforming ideas
|
||||
into scalable digital products. 25+ years of
|
||||
industry expertise, serving founders and CTOs
|
||||
across 15+ countries.
|
||||
</p>
|
||||
|
||||
{/* India Office Contact Information */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="font-semibold text-white text-sm tracking-wide uppercase">
|
||||
India Office
|
||||
</h5>
|
||||
<div className="space-y-3">
|
||||
{contactInfo.map((contact) => {
|
||||
const Icon = contact.icon;
|
||||
return (
|
||||
<a
|
||||
key={contact.label}
|
||||
href={contact.url}
|
||||
className="flex items-start gap-3 text-[#CCCCCC] hover:text-white transition-colors duration-200"
|
||||
>
|
||||
<Icon className="w-4 h-4 text-[#E5195E] mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm leading-relaxed">
|
||||
{contact.label}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Links */}
|
||||
<div className="flex gap-4 pt-4">
|
||||
{socialLinks.map((social) => {
|
||||
const Icon = social.icon;
|
||||
return (
|
||||
<a
|
||||
key={social.name}
|
||||
href={social.url}
|
||||
className="w-10 h-10 bg-white/5 rounded-lg flex items-center justify-center text-[#CCCCCC] hover:text-white hover:bg-[#E5195E]/20 transition-all duration-200"
|
||||
aria-label={social.name}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Navigation Sections */}
|
||||
<FooterSection
|
||||
title="Explore"
|
||||
links={footerNavigation.Explore}
|
||||
delay={0.1}
|
||||
/>
|
||||
<FooterSection
|
||||
title="Services"
|
||||
links={footerNavigation.Services}
|
||||
delay={0.2}
|
||||
/>
|
||||
<FooterSection
|
||||
title="AI & ML"
|
||||
links={footerNavigation["AI & ML"]}
|
||||
delay={0.3}
|
||||
/>
|
||||
<FooterSection
|
||||
title="Solutions"
|
||||
links={footerNavigation.Solutions}
|
||||
delay={0.4}
|
||||
/>
|
||||
<FooterSection
|
||||
title="Resources"
|
||||
links={footerNavigation.Resources}
|
||||
delay={0.5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Newsletter Subscription Section */}
|
||||
<NewsletterSection />
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="border-t border-white/10"
|
||||
>
|
||||
<div className="container mx-auto px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col lg:flex-row justify-between items-center gap-6">
|
||||
<div className="text-[#CCCCCC] text-sm text-center lg:text-left">
|
||||
© 2024 Web Development Institute. All rights
|
||||
reserved.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-6 text-sm">
|
||||
<a
|
||||
href="/privacy"
|
||||
className="text-[#CCCCCC] hover:text-white transition-colors"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a
|
||||
href="/terms"
|
||||
className="text-[#CCCCCC] hover:text-white transition-colors"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
<a
|
||||
href="/cookies"
|
||||
className="text-[#CCCCCC] hover:text-white transition-colors"
|
||||
>
|
||||
Cookie Policy
|
||||
</a>
|
||||
<a
|
||||
href="/sitemap"
|
||||
className="text-[#CCCCCC] hover:text-white transition-colors"
|
||||
>
|
||||
Sitemap
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="text-[#CCCCCC] text-sm text-center lg:text-right">
|
||||
Engineered by WDI — because someone had to do it
|
||||
right. 💻
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
26
components/GridPattern.tsx
Normal file
26
components/GridPattern.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export const GridPattern = ({ strokeDasharray = "4 2" }: { strokeDasharray?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full opacity-20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="grid"
|
||||
width="40"
|
||||
height="40"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path
|
||||
d="M 40 0 L 0 0 0 40"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.3)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
83
components/HeroBanner.tsx
Normal file
83
components/HeroBanner.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Button } from "./ui/button";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { navigateTo } from "../App";
|
||||
|
||||
interface HeroBannerProps {
|
||||
category?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryCTA: {
|
||||
text: string;
|
||||
href: string;
|
||||
};
|
||||
secondaryCTA?: {
|
||||
text: string;
|
||||
href: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function HeroBanner({
|
||||
category,
|
||||
title,
|
||||
description,
|
||||
primaryCTA,
|
||||
secondaryCTA
|
||||
}: HeroBannerProps) {
|
||||
return (
|
||||
<section className="relative py-20 lg:py-32 bg-[#0E0E0E] overflow-hidden">
|
||||
<GridPattern />
|
||||
|
||||
<div className="container mx-auto px-6 lg:px-8 relative z-10">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
{/* Category Badge */}
|
||||
{category && (
|
||||
<div className="inline-flex items-center rounded-full px-4 py-2 mb-8 bg-[#E5195E]/10 border border-[#E5195E]/20">
|
||||
<span className="text-[#E5195E] text-sm font-medium">
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Title */}
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-semibold tracking-tight text-white mb-6">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-lg lg:text-xl text-gray-400 mb-10 max-w-3xl mx-auto leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-[#E5195E] to-[#C41653] hover:from-[#C41653] hover:to-[#A31348] text-white font-semibold px-8 py-4 h-auto text-base"
|
||||
onClick={() => navigateTo(primaryCTA.href)}
|
||||
>
|
||||
{primaryCTA.text}
|
||||
</Button>
|
||||
|
||||
{secondaryCTA && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="bg-white/5 hover:bg-white/10 text-white border-white/20 hover:border-white/30 font-medium px-8 py-4 h-auto text-base"
|
||||
onClick={() => navigateTo(secondaryCTA.href)}
|
||||
>
|
||||
{secondaryCTA.text}
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute top-1/4 left-1/4 w-32 h-32 bg-gradient-to-r from-[#E5195E]/10 to-purple-500/10 rounded-full blur-3xl animate-pulse opacity-60"></div>
|
||||
<div className="absolute top-3/4 right-1/4 w-24 h-24 bg-gradient-to-r from-blue-500/10 to-cyan-500/10 rounded-full blur-2xl animate-pulse delay-1000 opacity-60"></div>
|
||||
<div className="absolute bottom-1/4 left-1/3 w-20 h-20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 rounded-full blur-2xl animate-pulse delay-2000 opacity-60"></div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
90
components/HeroSection.tsx
Normal file
90
components/HeroSection.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Button } from "./ui/button";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
import { SplineFallback } from "./SplineFallback";
|
||||
import { Calendar, Briefcase } from "lucide-react";
|
||||
import { navigateTo } from "../App";
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section id="hero" className="relative lg:min-h-[85vh] flex items-center pt-20">
|
||||
<GridPattern />
|
||||
|
||||
<div className="container mx-auto px-6 lg:px-8">
|
||||
<div className="flex flex-col-reverse lg:flex-row items-center gap-12 w-full py-24 relative z-10">
|
||||
<div className="w-full lg:w-1/2">
|
||||
{/* Animated Badge */}
|
||||
<div className="group relative inline-flex items-center rounded-full px-4 py-1.5 shadow-[inset_0_-8px_10px_#8fdfff1f] transition-shadow duration-500 ease-out hover:shadow-[inset_0_-5px_10px_#8fdfff3f] mb-6">
|
||||
<span
|
||||
className="absolute inset-0 block h-full w-full animate-gradient rounded-[inherit] bg-gradient-to-r from-[#ffaa40]/50 via-[#9c40ff]/50 to-[#ffaa40]/50 bg-[length:300%_100%] p-[1px]"
|
||||
style={{
|
||||
WebkitMask: "linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0)",
|
||||
WebkitMaskComposite: "destination-out",
|
||||
maskComposite: "subtract",
|
||||
}}
|
||||
/>
|
||||
<span className="relative z-10 flex items-center text-sm font-medium">
|
||||
🎉
|
||||
<span aria-hidden="true" className="mx-2 h-4 w-px shrink-0 bg-neutral-500" />
|
||||
<span className="bg-clip-text text-transparent bg-[linear-gradient(90deg,#ffaa40_0%,#9c40ff_50%,#ffaa40_100%)] bg-[length:200%_100%] animate-[gradientMove_6s_ease_infinite]">
|
||||
25+ Years Of Industry Expertise
|
||||
</span>
|
||||
<svg
|
||||
className="ml-1 w-4 h-4 stroke-neutral-500 transition-transform duration-300 group-hover:translate-x-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-semibold tracking-tight text-white max-w-3xl">
|
||||
Architecting Digital Success for Startups & Enterprises
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-2xl text-lg text-gray-400">
|
||||
We design and build secure, AI-powered apps and software tailored for scale, speed, and user engagement.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col sm:flex-row gap-3">
|
||||
<Button size="lg" className="whitespace-nowrap" onClick={() => navigateTo('/contact')}>
|
||||
<Calendar className="w-4 h-4" />
|
||||
Book a Free Consultation
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" size="lg" className="whitespace-nowrap" onClick={() => navigateTo('/services')}>
|
||||
<Briefcase className="w-4 h-4" />
|
||||
Explore Services
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-1/2 h-[320px] md:h-[480px] lg:h-[560px] shrink-0 relative">
|
||||
{/* Animated Background Elements */}
|
||||
<div className="absolute inset-0 overflow-hidden rounded-xl">
|
||||
<div className="absolute top-1/4 left-1/4 w-32 h-32 bg-gradient-to-r from-[#E5195E]/20 to-purple-500/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute top-3/4 right-1/4 w-24 h-24 bg-gradient-to-r from-blue-500/20 to-cyan-500/20 rounded-full blur-2xl animate-pulse delay-1000"></div>
|
||||
<div className="absolute bottom-1/4 left-1/3 w-20 h-20 bg-gradient-to-r from-green-500/20 to-emerald-500/20 rounded-full blur-2xl animate-pulse delay-2000"></div>
|
||||
</div>
|
||||
|
||||
{/* Interactive 3D-like Animation */}
|
||||
<div className="relative w-full h-full rounded-xl overflow-hidden border border-gray-800/50 bg-gradient-to-br from-gray-900/50 to-gray-800/30 backdrop-blur-sm">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#E5195E]/5 to-purple-500/5 rounded-xl"></div>
|
||||
<div className="relative z-10 w-full h-full">
|
||||
<SplineFallback />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Elements */}
|
||||
<div className="absolute bottom-10 left-1/2 transform -translate-x-1/2 animate-bounce">
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
95
components/HorizontalTagScroller.tsx
Normal file
95
components/HorizontalTagScroller.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
CreditCard,
|
||||
Heart,
|
||||
ShoppingCart,
|
||||
GraduationCap,
|
||||
Truck,
|
||||
Video,
|
||||
Building,
|
||||
Plane,
|
||||
Factory,
|
||||
Wheat,
|
||||
Gamepad2,
|
||||
Cloud
|
||||
} from "lucide-react";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
|
||||
const industries = [
|
||||
// First row
|
||||
{ name: "FinTech", icon: CreditCard },
|
||||
{ name: "HealthTech", icon: Heart },
|
||||
{ name: "eCommerce", icon: ShoppingCart },
|
||||
{ name: "EdTech", icon: GraduationCap },
|
||||
// Second row
|
||||
{ name: "Logistics", icon: Truck },
|
||||
{ name: "Media & OTT", icon: Video },
|
||||
{ name: "Real Estate", icon: Building },
|
||||
{ name: "Travel", icon: Plane },
|
||||
// Third row (we'll make it 3x4 instead to fit all 12)
|
||||
{ name: "Manufacturing", icon: Factory },
|
||||
{ name: "AgriTech", icon: Wheat },
|
||||
{ name: "Gaming", icon: Gamepad2 },
|
||||
{ name: "SaaS", icon: Cloud }
|
||||
];
|
||||
|
||||
const IndustryCard = ({ industry, index }: {
|
||||
industry: { name: string; icon: any };
|
||||
index: number;
|
||||
}) => {
|
||||
const Icon = industry.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{ y: -5, scale: 1.02 }}
|
||||
className="group p-6 rounded-xl bg-white/5 backdrop-blur-sm border border-white/10 hover:border-[#E5195E]/50 transition-all duration-300 cursor-pointer text-center"
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-xl bg-[#E5195E]/10 flex items-center justify-center group-hover:scale-110 group-hover:bg-[#E5195E]/20 transition-all duration-300">
|
||||
<Icon className="w-8 h-8 text-[#E5195E]" />
|
||||
</div>
|
||||
<h3 className="text-white font-medium text-lg group-hover:text-[#E5195E] transition-colors duration-300">
|
||||
{industry.name}
|
||||
</h3>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const HorizontalTagScroller = () => {
|
||||
return (
|
||||
<section className="relative py-20 bg-[#0E0E0E] overflow-hidden">
|
||||
<GridPattern strokeDasharray="4 2" />
|
||||
|
||||
<div className="relative z-10 container mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl lg:text-4xl font-semibold text-white mb-4">
|
||||
Tailored Solutions for Your Industry
|
||||
</h2>
|
||||
<p className="text-[#CCCCCC] text-lg max-w-2xl mx-auto">
|
||||
We serve diverse industries with specialized expertise and domain knowledge
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* 4x3 Grid for larger screens, 2x6 for tablets, 1x12 for mobile */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
|
||||
{industries.map((industry, index) => (
|
||||
<IndustryCard
|
||||
key={industry.name}
|
||||
industry={industry}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
214
components/InlineCTA.tsx
Normal file
214
components/InlineCTA.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Lightbulb, Clock } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
import { navigateTo } from "../App";
|
||||
|
||||
export const InlineCTA = () => {
|
||||
return (
|
||||
<section className="relative py-20 overflow-hidden">
|
||||
<GridPattern strokeDasharray="4 2" />
|
||||
|
||||
<div className="relative z-10 container mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="max-w-4xl mx-auto text-center"
|
||||
>
|
||||
<div className="mb-8">
|
||||
<motion.div
|
||||
className="w-20 h-20 mx-auto mb-6 bg-white/10 backdrop-blur-sm rounded-full border border-white/20 flex items-center justify-center relative"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Animated glow effect */}
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-accent/20"
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.3, 0.6, 0.3],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pulsing ring */}
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border border-accent/30"
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
opacity: [0.5, 0.8, 0.5],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 0.5
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main icon with subtle animation */}
|
||||
<motion.div
|
||||
animate={{
|
||||
rotate: [0, 5, -5, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
<Lightbulb className="w-10 h-10 text-accent relative z-10" />
|
||||
</motion.div>
|
||||
|
||||
{/* Sparkle effects */}
|
||||
<motion.div
|
||||
className="absolute -top-1 -right-1 w-2 h-2 bg-accent rounded-full"
|
||||
animate={{
|
||||
scale: [0, 1.2, 0],
|
||||
opacity: [0, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.8,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 0.3
|
||||
}}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="absolute -bottom-1 -left-1 w-1.5 h-1.5 bg-accent rounded-full"
|
||||
animate={{
|
||||
scale: [0, 1, 0],
|
||||
opacity: [0, 0.8, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2.2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 0.8
|
||||
}}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="absolute top-2 -left-2 w-1 h-1 bg-accent rounded-full"
|
||||
animate={{
|
||||
scale: [0, 1.5, 0],
|
||||
opacity: [0, 0.6, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2.5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 1.2
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.h2
|
||||
className="text-3xl lg:text-5xl font-semibold text-foreground mb-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
Have an Idea? Let's Talk.
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
Get clarity, timelines, and answers within 24 hours.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8"
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-accent hover:bg-accent/90 text-accent-foreground px-8 py-4 text-lg border-0 rounded-lg shadow-lg hover:shadow-xl transition-all duration-300"
|
||||
onClick={() => navigateTo('/contact')}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
rotate: [0, 10, -10, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
className="mr-2"
|
||||
>
|
||||
<Lightbulb className="w-5 h-5" />
|
||||
</motion.div>
|
||||
Request a Proposal
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-sm">24-hour response guarantee</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="grid grid-cols-3 gap-8 max-w-2xl mx-auto text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.7 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<div className="text-2xl font-bold text-foreground">15min</div>
|
||||
<div className="text-sm text-muted-foreground">Quick Discovery Call</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<div className="text-2xl font-bold text-foreground">24hrs</div>
|
||||
<div className="text-sm text-muted-foreground">Detailed Proposal</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.9 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<div className="text-2xl font-bold text-foreground">48hrs</div>
|
||||
<div className="text-sm text-muted-foreground">Project Kickoff</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
843
components/Navigation.tsx
Normal file
843
components/Navigation.tsx
Normal file
@@ -0,0 +1,843 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ChevronDown,
|
||||
Menu,
|
||||
X,
|
||||
Code,
|
||||
Smartphone,
|
||||
Globe,
|
||||
Palette,
|
||||
Brain,
|
||||
Users,
|
||||
Building2,
|
||||
Monitor,
|
||||
ShoppingCart,
|
||||
Server,
|
||||
Wrench,
|
||||
Lightbulb,
|
||||
Database,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
Target,
|
||||
BarChart3,
|
||||
Zap,
|
||||
Rocket,
|
||||
Shield,
|
||||
Cog,
|
||||
HeartHandshake,
|
||||
GraduationCap,
|
||||
Stethoscope,
|
||||
ShoppingBag,
|
||||
Truck,
|
||||
Gamepad2,
|
||||
Factory,
|
||||
DollarSign,
|
||||
Home,
|
||||
BookOpen,
|
||||
Users2,
|
||||
Code2,
|
||||
Laptop,
|
||||
Paintbrush,
|
||||
Bot,
|
||||
RefreshCw,
|
||||
Info,
|
||||
Clock,
|
||||
Award,
|
||||
Briefcase,
|
||||
Heart,
|
||||
Newspaper,
|
||||
FileText,
|
||||
Star,
|
||||
HelpCircle,
|
||||
Mail,
|
||||
FileCheck,
|
||||
Phone,
|
||||
MapPin,
|
||||
Headphones,
|
||||
UserPlus,
|
||||
Apple,
|
||||
GitMerge,
|
||||
Gauge,
|
||||
Chrome,
|
||||
Watch,
|
||||
Cloud,
|
||||
CloudCog,
|
||||
Link,
|
||||
PenTool,
|
||||
MousePointer2,
|
||||
TestTube,
|
||||
PlayCircle,
|
||||
Search,
|
||||
Workflow,
|
||||
Sparkles,
|
||||
Wand2,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
Settings,
|
||||
ArrowRight,
|
||||
Calculator,
|
||||
Calendar,
|
||||
FileEdit
|
||||
} from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import BlackLogo14 from "../imports/BlackLogo14";
|
||||
import { navigateTo } from "../App";
|
||||
|
||||
const navigationData = {
|
||||
main_navigation: [
|
||||
"Services",
|
||||
"AI & ML",
|
||||
"Solutions",
|
||||
"Industries",
|
||||
"Hire Talent",
|
||||
"Company",
|
||||
"Resources"
|
||||
],
|
||||
services: [
|
||||
{
|
||||
category: "Mobile App Development",
|
||||
icon: Smartphone,
|
||||
href: "/services/mobile-app-development",
|
||||
sub_services: [
|
||||
{ name: "iOS App Development", href: "/services/ios-app-development" },
|
||||
{ name: "Android App Development", href: "/services/android-app-development" },
|
||||
{ name: "Cross-Platform App Development", href: "/services/cross-platform-app-development" },
|
||||
{ name: "Native App Development", href: "/services/native-app-development" },
|
||||
{ name: "Progressive Web Apps (PWAs)", href: "/services/pwa-development" },
|
||||
{ name: "App Development for Wearables & Devices", href: "/services/wearable-device-development" }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "Web & Cloud Solutions",
|
||||
icon: Globe,
|
||||
href: "/web-cloud",
|
||||
sub_services: [
|
||||
{ name: "Custom Web Application Development", href: "/services/custom-web-app-development" },
|
||||
{ name: "SaaS Product Engineering", href: "/services/saas-product-engineering" },
|
||||
{ name: "eCommerce Platforms", href: "/services/ecommerce-platforms" },
|
||||
{ name: "Admin Panels & Dashboards", href: "/services/admin-panels-dashboards" },
|
||||
{ name: "API & Backend Development", href: "/services/api-backend-development" }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "Software Engineering",
|
||||
icon: Code2,
|
||||
href: "/software-engineering",
|
||||
sub_services: [
|
||||
{ name: "Enterprise Software Solutions", href: "/services/enterprise-software-solutions" },
|
||||
{ name: "System Architecture & DevOps", href: "/services/system-architecture-devops" },
|
||||
{ name: "Third-Party Integrations", href: "/services/third-party-integrations" },
|
||||
{ name: "Product Modernization", href: "/services/product-modernization" }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "Design & Experience",
|
||||
icon: Paintbrush,
|
||||
href: "/design-experience",
|
||||
sub_services: [
|
||||
{ name: "UI/UX Design", href: "/services/ui-ux-design" },
|
||||
{ name: "Clickable Prototypes", href: "/services/clickable-prototypes" },
|
||||
{ name: "Design Thinking Workshops", href: "/services/design-thinking-workshops" },
|
||||
{ name: "User Research & Testing", href: "/services/user-research-testing" }
|
||||
]
|
||||
}
|
||||
],
|
||||
ai_data_intelligence: [
|
||||
{
|
||||
category: "Artificial Intelligence Services",
|
||||
icon: Bot,
|
||||
href: "/artificial-intelligence",
|
||||
sub_services: [
|
||||
{ name: "AI Strategy & Consulting", href: "/services/ai-strategy-consulting" },
|
||||
{ name: "AI-Powered Automation & Workflows", href: "/services/ai-automation-workflows" },
|
||||
{ name: "AI Integration into Digital Products", href: "/services/ai-integration-digital-products" },
|
||||
{ name: "Gen AI Integration into Digital Products", href: "/services/gen-ai-integration-digital-products" },
|
||||
{ name: "AI Chatbots & Virtual Assistants", href: "/services/ai-chatbots-virtual-assistants" },
|
||||
{ name: "AI Model Deployment & Maintenance", href: "/services/ai-model-deployment-mlops" }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "Machine Learning Solutions",
|
||||
icon: Brain,
|
||||
href: "/machine-learning",
|
||||
sub_services: [
|
||||
{ name: "Custom ML Model Development", href: "/services/custom-ml-model-development" },
|
||||
{ name: "Predictive Analytics & Forecasting", href: "/services/predictive-analytics-forecasting" },
|
||||
{ name: "Computer Vision Applications", href: "/services/computer-vision-applications" },
|
||||
{ name: "NLP & Text Analytics", href: "/services/nlp-text-analytics" },
|
||||
{ name: "Recommendation Engines", href: "/services/recommendation-engines" }
|
||||
]
|
||||
}
|
||||
],
|
||||
solutions: [
|
||||
{ text: "Digital Product Development", icon: Rocket, href: "/solutions/digital-product-development" },
|
||||
{ text: "MVP & Startup Launch Packages", icon: Zap, href: "/solutions/mvp-startup-launch-packages" },
|
||||
{ text: "Legacy System Rebuilds", icon: RefreshCw, href: "/solutions/legacy-system-rebuilds" },
|
||||
{ text: "Dedicated Offshore Development Centers (ODC)", icon: Building2, href: "/solutions/dedicated-offshore-odc" },
|
||||
{ text: "Business Process Automation", icon: Cog, href: "/solutions/business-process-automation" },
|
||||
{ text: "Compliance-Ready Systems (HIPAA, GDPR, etc.)", icon: Shield, href: "/solutions/compliance-ready-systems" }
|
||||
],
|
||||
industries: [
|
||||
{
|
||||
group: "Financial Services",
|
||||
icon: DollarSign,
|
||||
items: [
|
||||
{ name: "FinTech & Banking Apps", href: "/industries/fintech-banking-apps" },
|
||||
{ name: "WealthTech Platforms", href: "/industries/financial-services/wealthtech-platforms" },
|
||||
{ name: "Real Estate Tech", href: "/industries/financial-services/real-estate-tech" }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: "Healthcare & Wellness",
|
||||
icon: Stethoscope,
|
||||
items: [
|
||||
{ name: "HealthTech Applications", href: "/industries/healthcare/healthtech-applications" },
|
||||
{ name: "Medical Compliance Solutions", href: "/industries/healthcare/medical-compliance-solutions" },
|
||||
{ name: "Fitness & Wellness Platforms", href: "/industries/healthcare/fitness-wellness-platforms" }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: "Learning & Education",
|
||||
icon: GraduationCap,
|
||||
items: [
|
||||
{ name: "EdTech Platforms", href: "/industries/education/edtech-platforms" },
|
||||
{ name: "Virtual Classrooms & LMS", href: "/industries/education/virtual-classrooms-lms" },
|
||||
{ name: "Microlearning Apps", href: "/industries/education/microlearning-apps" }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: "Commerce & Consumer",
|
||||
icon: ShoppingBag,
|
||||
items: [
|
||||
{ name: "eCommerce & Marketplaces", href: "/industries/commerce/ecommerce-marketplaces" },
|
||||
{ name: "Food Ordering & Delivery", href: "/industries/commerce/food-ordering-delivery" },
|
||||
{ name: "Travel & Booking Systems", href: "/industries/commerce/travel-booking-systems" },
|
||||
{ name: "Event & Ticketing Solutions", href: "/industries/commerce/event-ticketing-solutions" }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: "Media & Community",
|
||||
icon: Gamepad2,
|
||||
items: [
|
||||
{ name: "OTT & Streaming Apps", href: "/industries/media/ott-streaming-apps" },
|
||||
{ name: "Social Platforms & Networks", href: "/industries/media/social-platforms-networks" },
|
||||
{ name: "Sports & Fan Engagement", href: "/industries/media/sports-fan-engagement" }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: "Mobility & Logistics",
|
||||
icon: Truck,
|
||||
items: [
|
||||
{ name: "Transportation Apps", href: "/industries/mobility/transportation-apps" },
|
||||
{ name: "On-Demand Services", href: "/industries/mobility/on-demand-services" },
|
||||
{ name: "Supply Chain & Fleet Management", href: "/industries/mobility/supply-chain-fleet-management" }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: "Industrial & Emerging Tech",
|
||||
icon: Factory,
|
||||
items: [
|
||||
{ name: "Manufacturing Automation", href: "/industries/industrial/manufacturing-automation" },
|
||||
{ name: "AgriTech Platforms", href: "/industries/industrial/agritech-platforms" },
|
||||
{ name: "Oil & Gas Monitoring Systems", href: "/industries/industrial/oil-gas-monitoring-systems" }
|
||||
]
|
||||
}
|
||||
],
|
||||
hire_talent: [
|
||||
{ text: "Hire Mobile App Developers", icon: Smartphone, href: "/hire-talent/mobile-app-developers" },
|
||||
{ text: "Hire Full Stack Developers", icon: Code, href: "/hire-talent/full-stack-developers" },
|
||||
{ text: "Hire Frontend Developers", icon: Monitor, href: "/hire-talent/frontend-developers" },
|
||||
{ text: "Hire Backend Developers", icon: Database, href: "/hire-talent/backend-developers" },
|
||||
{ text: "Hire UI/UX Designers", icon: Palette, href: "/hire-talent/ui-ux-designers" },
|
||||
{ text: "Hire QA Engineers", icon: TestTube, href: "/hire-talent/qa-engineers" },
|
||||
{ text: "Dedicated Development Teams", icon: Users, href: "/dedicated-development-teams" },
|
||||
{ text: "Engagement Models", icon: Settings, href: "/engagement-models" },
|
||||
{ text: "Team Augmentation Services", icon: Zap, href: "/team-augmentation-services" }
|
||||
],
|
||||
company: [
|
||||
{ text: "About WDI", icon: Info, href: "/company/about-wdi" },
|
||||
{ text: "Our History", icon: Clock, href: "/company/our-history" },
|
||||
{ text: "Leadership Team", icon: Users2, href: "/company/leadership-team" },
|
||||
{ text: "Awards & Certifications", icon: Award, href: "/company/awards-certifications" },
|
||||
{ text: "Careers", icon: Briefcase, href: "/company/careers" },
|
||||
{ text: "Culture & Values", icon: Heart, href: "/company/culture-values" },
|
||||
{ text: "Press & Media", icon: Newspaper, href: "/company/press-media" }
|
||||
],
|
||||
resources: [
|
||||
{ text: "Articles", icon: BookOpen, href: "/resources/blog" },
|
||||
{ text: "Case Studies", icon: FileText, href: "/case-studies" },
|
||||
{ text: "Client Testimonials", icon: Star, href: "/resources/client-testimonials" },
|
||||
{ text: "Whitepapers & Insights", icon: FileCheck, href: "/resources/whitepapers-insights" },
|
||||
{ text: "FAQs", icon: HelpCircle, href: "/resources/faqs" }
|
||||
],
|
||||
contact: [
|
||||
{ text: "Contact Form", icon: Mail, href: "/contact" },
|
||||
{ text: "Request a Proposal", icon: FileCheck, href: "/contact/request-a-proposal" },
|
||||
{ text: "Schedule a Discovery Call", icon: Phone, href: "/contact/schedule-a-discovery-call" },
|
||||
{ text: "Office Locations", icon: MapPin, href: "/contact/office-locations" },
|
||||
{ text: "Client Support", icon: Headphones, href: "/contact/client-support" },
|
||||
{ text: "Send your CV to HR", icon: UserPlus, href: "/contact/send-your-cv" }
|
||||
]
|
||||
};
|
||||
|
||||
// CTA configurations for each mega menu type - UPDATED ALL TO LINK TO START A PROJECT PAGE
|
||||
const megaMenuCTAs = {
|
||||
Services: {
|
||||
title: "Development Quote",
|
||||
subtitle: "Get a custom quote for your project",
|
||||
buttonText: "Get Started",
|
||||
href: "/start-a-project",
|
||||
icon: Calculator
|
||||
},
|
||||
"AI & ML": {
|
||||
title: "AI Strategy Session",
|
||||
subtitle: "Discover AI opportunities for your business",
|
||||
buttonText: "Book Session",
|
||||
href: "/start-a-project",
|
||||
icon: Bot
|
||||
},
|
||||
Solutions: {
|
||||
title: "Solution Consultation",
|
||||
subtitle: "Find the perfect solution for your business",
|
||||
buttonText: "Consult Now",
|
||||
href: "/start-a-project",
|
||||
icon: Lightbulb
|
||||
},
|
||||
Industries: {
|
||||
title: "Industry Expertise",
|
||||
subtitle: "Learn how we transform your industry",
|
||||
buttonText: "Explore",
|
||||
href: "/start-a-project",
|
||||
icon: Building2
|
||||
},
|
||||
"Hire Talent": {
|
||||
title: "Team Assessment",
|
||||
subtitle: "Get matched with the right talent",
|
||||
buttonText: "Start Hiring",
|
||||
href: "/start-a-project",
|
||||
icon: Users
|
||||
},
|
||||
Company: {
|
||||
title: "Schedule a Call",
|
||||
subtitle: "Let's discuss your project requirements",
|
||||
buttonText: "Book Call",
|
||||
href: "/start-a-project",
|
||||
icon: Calendar
|
||||
},
|
||||
Resources: {
|
||||
title: "Free Consultation",
|
||||
subtitle: "Get expert insights for your project",
|
||||
buttonText: "Get Started",
|
||||
href: "/start-a-project",
|
||||
icon: FileEdit
|
||||
},
|
||||
Contact: {
|
||||
title: "Start Your Project",
|
||||
subtitle: "Ready to bring your idea to life?",
|
||||
buttonText: "Get Started",
|
||||
href: "/start-a-project",
|
||||
icon: Rocket
|
||||
}
|
||||
};
|
||||
|
||||
// Horizontal CTA Component matching reference design
|
||||
const MegaMenuCTA = ({ type }: { type: string }) => {
|
||||
const cta = megaMenuCTAs[type as keyof typeof megaMenuCTAs];
|
||||
if (!cta) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
className="mt-8 p-6 bg-gradient-to-r from-gray-900/60 to-gray-800/60 backdrop-blur-sm border border-gray-700/30 rounded-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white font-semibold text-lg mb-1">{cta.title}</h3>
|
||||
<p className="text-gray-400 text-sm leading-relaxed">{cta.subtitle}</p>
|
||||
</div>
|
||||
<div className="ml-6">
|
||||
<Button
|
||||
className="bg-gradient-to-r from-[#E5195E] to-[#C41653] hover:from-[#C41653] hover:to-[#A31348] text-white font-medium text-sm px-6 py-3 h-auto rounded-xl shadow-lg hover:shadow-xl transition-all duration-200 group"
|
||||
onClick={() => navigateTo(cta.href)}
|
||||
>
|
||||
{cta.buttonText}
|
||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MegaMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCancelClose: () => void;
|
||||
type: string;
|
||||
timeoutRef?: React.MutableRefObject<NodeJS.Timeout | undefined>;
|
||||
}
|
||||
|
||||
const MegaMenu = ({ isOpen, onClose, onCancelClose, type, timeoutRef }: MegaMenuProps) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const navigate = (path: string) => {
|
||||
navigateTo(path);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderServicesMenu = () => (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{navigationData.services.map((service, index) => {
|
||||
const Icon = service.icon;
|
||||
return (
|
||||
<div key={service.category} className="space-y-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#E5195E]/20 flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-[#E5195E]" />
|
||||
</div>
|
||||
<h4
|
||||
className="font-semibold text-white text-sm cursor-pointer hover:text-[#E5195E] transition-colors"
|
||||
onClick={() => service.href && navigate(service.href)}
|
||||
>
|
||||
{service.category}
|
||||
</h4>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
{service.sub_services.map((subService) => (
|
||||
<li key={subService.name}>
|
||||
<a
|
||||
href="#"
|
||||
className="text-[#CCCCCC] hover:text-white text-sm transition-colors duration-200 block py-1 hover:translate-x-1 transform"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (subService.href) {
|
||||
navigate(subService.href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{subService.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<MegaMenuCTA type="Services" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAIMenu = () => (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{navigationData.ai_data_intelligence.map((category) => {
|
||||
const Icon = category.icon;
|
||||
return (
|
||||
<div key={category.category} className="space-y-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#E5195E]/20 flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-[#E5195E]" />
|
||||
</div>
|
||||
<h4
|
||||
className="font-semibold text-white text-lg cursor-pointer hover:text-[#E5195E] transition-colors"
|
||||
onClick={() => category.href && navigate(category.href)}
|
||||
>
|
||||
{category.category}
|
||||
</h4>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
{category.sub_services.map((service) => (
|
||||
<li key={service.name}>
|
||||
<a
|
||||
href="#"
|
||||
className="text-[#CCCCCC] hover:text-white text-sm transition-colors duration-200 block py-1 hover:translate-x-1 transform"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (service.href) {
|
||||
navigate(service.href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{service.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<MegaMenuCTA type="AI & ML" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSolutionsMenu = () => (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{navigationData.solutions.map((solution) => {
|
||||
const Icon = solution.icon;
|
||||
return (
|
||||
<a
|
||||
key={solution.text}
|
||||
href="#"
|
||||
className="flex items-center gap-4 text-[#CCCCCC] hover:text-white transition-all duration-200 p-4 rounded-lg hover:bg-white/5 group"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (solution.href) {
|
||||
navigate(solution.href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-[#E5195E]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<Icon className="w-4 h-4 text-[#E5195E]" />
|
||||
</div>
|
||||
<span className="font-medium">{solution.text}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<MegaMenuCTA type="Solutions" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderIndustriesMenu = () => (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{navigationData.industries.map((industry) => {
|
||||
const Icon = industry.icon;
|
||||
return (
|
||||
<div key={industry.group} className="space-y-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#E5195E]/20 flex items-center justify-center">
|
||||
<Icon className="w-4 h-4 text-[#E5195E]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-white text-sm border-b border-white/10 pb-2">
|
||||
{industry.group}
|
||||
</h4>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{industry.items.map((item) => (
|
||||
<li key={item.name}>
|
||||
<a
|
||||
href="#"
|
||||
className="text-[#CCCCCC] hover:text-white text-sm transition-colors duration-200 block py-1 hover:translate-x-1 transform"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (item.href) {
|
||||
navigate(item.href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<MegaMenuCTA type="Industries" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderGenericMenu = (items: any[], menuType: string) => (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<a
|
||||
key={item.text}
|
||||
href="#"
|
||||
className="flex items-center gap-4 text-[#CCCCCC] hover:text-white transition-all duration-200 p-4 rounded-lg hover:bg-white/5 group"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (item.href) {
|
||||
navigate(item.href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-[#E5195E]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<Icon className="w-4 h-4 text-[#E5195E]" />
|
||||
</div>
|
||||
<span className="font-medium">{item.text}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<MegaMenuCTA type={menuType} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const getMenuContent = () => {
|
||||
switch (type) {
|
||||
case 'Services':
|
||||
return renderServicesMenu();
|
||||
case 'AI & ML':
|
||||
return renderAIMenu();
|
||||
case 'Solutions':
|
||||
return renderSolutionsMenu();
|
||||
case 'Industries':
|
||||
return renderIndustriesMenu();
|
||||
case 'Hire Talent':
|
||||
return renderGenericMenu(navigationData.hire_talent, 'Hire Talent');
|
||||
case 'Company':
|
||||
return renderGenericMenu(navigationData.company, 'Company');
|
||||
case 'Resources':
|
||||
return renderGenericMenu(navigationData.resources, 'Resources');
|
||||
case 'Contact':
|
||||
return renderGenericMenu(navigationData.contact, 'Contact');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute top-full left-0 w-full bg-[#121212] backdrop-blur-xl border-t border-white/10 shadow-xl z-50 nav-mega-menu"
|
||||
style={{ minHeight: '400px' }}
|
||||
onMouseEnter={onCancelClose}
|
||||
onMouseLeave={onClose}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[#121212] to-[#0E0E0E] opacity-95" />
|
||||
<div
|
||||
className="absolute inset-0 opacity-5"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 1px 1px, rgba(255,255,255,0.1) 1px, transparent 0)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 container mx-auto px-6 lg:px-8 py-12">
|
||||
{getMenuContent()}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Navigation = () => {
|
||||
const [activeMenu, setActiveMenu] = useState<string | null>(null);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
|
||||
const navigate = (path: string) => {
|
||||
navigateTo(path);
|
||||
};
|
||||
|
||||
const openMenu = useCallback((item: string) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (['Services', 'AI & ML', 'Solutions', 'Industries', 'Hire Talent', 'Company', 'Resources'].includes(item)) {
|
||||
setActiveMenu(item);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setActiveMenu(null);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const cancelClose = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNavItemMouseEnter = useCallback((item: string) => {
|
||||
cancelClose();
|
||||
openMenu(item);
|
||||
}, [cancelClose, openMenu]);
|
||||
|
||||
const handleNavItemMouseLeave = useCallback(() => {
|
||||
closeMenu();
|
||||
}, [closeMenu]);
|
||||
|
||||
const handleNavMouseLeave = useCallback((e: React.MouseEvent) => {
|
||||
const relatedTarget = e.relatedTarget as Element;
|
||||
if (!navRef.current?.contains(relatedTarget)) {
|
||||
closeMenu();
|
||||
}
|
||||
}, [closeMenu]);
|
||||
|
||||
const handleNavMouseEnter = useCallback(() => {
|
||||
cancelClose();
|
||||
}, [cancelClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasDropdown = (item: string) => {
|
||||
return ['Services', 'AI & ML', 'Solutions', 'Industries', 'Hire Talent', 'Company', 'Resources'].includes(item);
|
||||
};
|
||||
|
||||
// Function to get main category page route for navigation items
|
||||
const getMainCategoryRoute = (item: string) => {
|
||||
switch (item) {
|
||||
case 'Services':
|
||||
return '/services';
|
||||
case 'Company':
|
||||
return '/company';
|
||||
case 'Resources':
|
||||
return '/resources';
|
||||
case 'Contact':
|
||||
return '/contact';
|
||||
case 'Hire Talent':
|
||||
return '/hire-talent';
|
||||
case 'AI & ML':
|
||||
return '/artificial-intelligence';
|
||||
case 'Solutions':
|
||||
return '/solutions/digital-product-development'; // Default to first solution
|
||||
case 'Industries':
|
||||
return '/industries/fintech-banking-apps'; // Default to first industry
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={navRef}
|
||||
className="fixed top-0 left-0 right-0 z-50 bg-[#0E0E0E]/90 backdrop-blur-lg border-b border-white/10"
|
||||
onMouseLeave={handleNavMouseLeave}
|
||||
onMouseEnter={handleNavMouseEnter}
|
||||
>
|
||||
<div className="container mx-auto px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
<div className="flex items-center">
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate('/');
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-10">
|
||||
<BlackLogo14 />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:flex items-center space-x-6 xl:space-x-8">
|
||||
{navigationData.main_navigation.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="relative nav-dropdown-trigger"
|
||||
onMouseEnter={() => handleNavItemMouseEnter(item)}
|
||||
onMouseLeave={handleNavItemMouseLeave}
|
||||
>
|
||||
<a
|
||||
href={`#${item.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className="flex items-center gap-1 text-[#CCCCCC] hover:text-white transition-colors duration-200 py-2 font-medium text-sm xl:text-base whitespace-nowrap"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const route = getMainCategoryRoute(item);
|
||||
if (route) {
|
||||
navigate(route);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
{hasDropdown(item) && (
|
||||
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${activeMenu === item ? 'rotate-180' : ''}`} />
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
onClick={() => navigate('/start-a-project')}
|
||||
className="hidden lg:flex"
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="lg:hidden text-[#CCCCCC] hover:text-white transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mega Menu */}
|
||||
<AnimatePresence>
|
||||
{activeMenu && (
|
||||
<MegaMenu
|
||||
isOpen={true}
|
||||
onClose={closeMenu}
|
||||
onCancelClose={cancelClose}
|
||||
type={activeMenu}
|
||||
timeoutRef={timeoutRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="lg:hidden bg-[#121212] border-t border-white/10"
|
||||
>
|
||||
<div className="container mx-auto px-6 py-6">
|
||||
<div className="space-y-4">
|
||||
{navigationData.main_navigation.map((item) => (
|
||||
<a
|
||||
key={item}
|
||||
href="#"
|
||||
className="block text-[#CCCCCC] hover:text-white transition-colors py-2 font-medium"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const route = getMainCategoryRoute(item);
|
||||
if (route) {
|
||||
navigate(route);
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</a>
|
||||
))}
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigate('/start-a-project');
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
className="w-full mt-4"
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
280
components/ProcessSection.tsx
Normal file
280
components/ProcessSection.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useRef } from "react";
|
||||
import { ArrowRight, FileText, Users, CheckCircle, Rocket, Search, Code, Palette, Monitor } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: "step-1",
|
||||
title: "1. Define Scope",
|
||||
description: "We begin by gathering all project inputs, defining clear goals, creating technical documentation, and aligning on expectations.",
|
||||
visual: {
|
||||
type: "icon_or_doc_mockup",
|
||||
style: "minimal_ui"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "step-2",
|
||||
title: "2. Design UI/UX",
|
||||
description: "Our designers craft user-centric interfaces in Figma with clickable flows, visual systems, and detailed wireframes for all screens.",
|
||||
tags: [
|
||||
{ label: "Wireframes", color: "#6366F1" },
|
||||
{ label: "Prototyping", color: "#10B981" },
|
||||
{ label: "UI System", color: "#F59E0B" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "step-3",
|
||||
title: "3. Develop with Agile Sprints",
|
||||
description: "We use Agile sprints to turn designs into production-ready code, with continuous integration and weekly builds.",
|
||||
tags: [
|
||||
{ label: "Frontend", color: "#3B82F6" },
|
||||
{ label: "Backend", color: "#0EA5E9" },
|
||||
{ label: "APIs", color: "#8B5CF6" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "step-4",
|
||||
title: "4. Test, Launch & Scale",
|
||||
description: "After QA and UAT, we help launch confidently. Then we monitor, iterate, and scale your product to grow with your users.",
|
||||
chat_simulation: [
|
||||
{ from: "You", text: "Are we ready to go live?" },
|
||||
{ from: "Team", text: "Yes! Final tests passed 🚀" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Chat simulation component - Compact version
|
||||
const ChatSimulation = ({ messages }: { messages: Array<{ from: string; text: string }> }) => {
|
||||
return (
|
||||
<div className="space-y-2 p-3 bg-background rounded-lg border border-border">
|
||||
{messages.map((message, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: message.from === "You" ? -20 : 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
className={`flex ${message.from === "You" ? "justify-start" : "justify-end"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] px-3 py-1.5 rounded-lg ${
|
||||
message.from === "You"
|
||||
? "bg-muted border border-border text-foreground"
|
||||
: "bg-accent text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium mb-1 opacity-70">{message.from}</div>
|
||||
<div className="text-xs">{message.text}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Document mockup component - Compact version
|
||||
const DocumentMockup = () => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="w-full bg-background rounded-lg border border-border p-4">
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-accent" />
|
||||
<span className="text-xs font-medium text-foreground">Project Scope</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Draft v1.2</div>
|
||||
</div>
|
||||
|
||||
{/* Document sections */}
|
||||
<div className="space-y-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, width: 0 }}
|
||||
whileInView={{ opacity: 1, width: "100%" }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="space-y-1.5"
|
||||
>
|
||||
<div className="h-1.5 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-1.5 bg-muted/60 rounded w-full"></div>
|
||||
<div className="h-1.5 bg-muted/60 rounded w-5/6"></div>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs"
|
||||
>
|
||||
<CheckCircle className="w-3 h-3 text-green-500" />
|
||||
<span className="text-muted-foreground">Requirements</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs"
|
||||
>
|
||||
<Search className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-muted-foreground">Research</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Process step card component
|
||||
const ProcessCard = ({ step, index }: { step: typeof steps[0]; index: number }) => {
|
||||
const cardRef = useRef(null);
|
||||
|
||||
const renderContent = () => {
|
||||
if (step.visual?.type === "icon_or_doc_mockup") {
|
||||
return <DocumentMockup />;
|
||||
}
|
||||
|
||||
if (step.chat_simulation) {
|
||||
return <ChatSimulation messages={step.chat_simulation} />;
|
||||
}
|
||||
|
||||
if (step.tags) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{step.tags.map((tag, tagIndex) => (
|
||||
<motion.div
|
||||
key={tagIndex}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4, delay: tagIndex * 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<Badge
|
||||
className="text-white border-0 px-3 py-1 text-xs font-medium rounded-lg"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
>
|
||||
{tag.label}
|
||||
</Badge>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={cardRef}
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: index * 0.15 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
className="bg-card rounded-lg border border-border hover:border-border/80 transition-all duration-300 overflow-hidden group hover:shadow-lg"
|
||||
>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="space-y-3">
|
||||
<motion.h3
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.15 + 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-foreground leading-tight text-lg"
|
||||
>
|
||||
{step.title}
|
||||
</motion.h3>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.15 + 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-muted-foreground leading-relaxed text-base"
|
||||
>
|
||||
{step.description}
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.15 + 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
{renderContent()}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProcessSection = () => {
|
||||
const titleRef = useRef(null);
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden py-20 bg-background">
|
||||
<div className="container mx-auto px-6 lg:px-8">
|
||||
{/* Title Section */}
|
||||
<div ref={titleRef} className="text-center mb-16">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-4xl lg:text-5xl font-semibold text-foreground mb-4"
|
||||
>
|
||||
How We Turn an Idea into a{" "}
|
||||
<span className="text-accent">Scalable Product</span>
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-muted-foreground text-xl max-w-2xl mx-auto"
|
||||
>
|
||||
Our proven process transforms your vision into reality through strategic planning,
|
||||
thoughtful design, and expert engineering—every step of the way.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-6xl mx-auto">
|
||||
{steps.map((step, index) => (
|
||||
<ProcessCard key={step.id} step={step} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mt-16"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-accent hover:bg-accent/90 text-accent-foreground border-0 rounded-lg px-8 py-4 group text-lg"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
Start Your Project Today
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
169
components/ResourceCards.tsx
Normal file
169
components/ResourceCards.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight, Calendar, Clock } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ImageWithFallback } from "./figma/ImageWithFallback";
|
||||
import { navigateTo } from "../App";
|
||||
|
||||
const resources = [
|
||||
{
|
||||
title: "UX review presentations",
|
||||
excerpt: "How do you create compelling presentations that wow clients, and actually close projects and deals? Here are the key insights that will elevate your game.",
|
||||
readTime: "8 min read",
|
||||
date: "Dec 15, 2024",
|
||||
image: "https://images.unsplash.com/photo-1560472355-536de3962603?w=800&h=400&fit=crop&auto=format",
|
||||
author: {
|
||||
name: "Olivia Rhye",
|
||||
avatar: "https://images.unsplash.com/photo-1494790108755-2616b612b47c?w=150&h=150&fit=crop&crop=face&auto=format"
|
||||
},
|
||||
category: "Design",
|
||||
slug: "ux-review-presentations"
|
||||
},
|
||||
{
|
||||
title: "Migrating to Linear 101",
|
||||
excerpt: "Linear helps streamline software projects, sprints, tasks, and bug tracking. Here's how to get started and make the most of it.",
|
||||
readTime: "6 min read",
|
||||
date: "Dec 10, 2024",
|
||||
image: "https://images.unsplash.com/photo-1551434678-e076c223a692?w=800&h=400&fit=crop&auto=format",
|
||||
author: {
|
||||
name: "Phoenix Baker",
|
||||
avatar: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face&auto=format"
|
||||
},
|
||||
category: "Software Engineering",
|
||||
slug: "migrating-to-linear-101"
|
||||
},
|
||||
{
|
||||
title: "Building your API Stack",
|
||||
excerpt: "The rise of RESTful APIs has been met by a rise in tools for creating, testing, and managing them. Here are the best practices for API development.",
|
||||
readTime: "12 min read",
|
||||
date: "Dec 5, 2024",
|
||||
image: "https://images.unsplash.com/photo-1555949963-ff9fe0c870eb?w=800&h=400&fit=crop&auto=format",
|
||||
author: {
|
||||
name: "Lana Steiner",
|
||||
avatar: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face&auto=format"
|
||||
},
|
||||
category: "Software Engineering",
|
||||
slug: "building-your-api-stack"
|
||||
}
|
||||
];
|
||||
|
||||
const ResourceCard = ({ resource, index }: { resource: typeof resources[0]; index: number }) => {
|
||||
return (
|
||||
<motion.article
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="group bg-card rounded-lg border border-border overflow-hidden hover:border-border/80 transition-all duration-300 hover:shadow-lg cursor-pointer"
|
||||
onClick={() => navigateTo(`/insights/${resource.slug}`)}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="aspect-[16/9] overflow-hidden relative">
|
||||
<ImageWithFallback
|
||||
src={resource.image}
|
||||
alt={resource.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
{/* Capsule Tag */}
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className="px-3 py-1.5 bg-background/90 backdrop-blur-sm text-foreground text-xs font-medium rounded-full border border-border/50">
|
||||
{resource.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Date and Read Time */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{resource.date}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{resource.readTime}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-foreground font-medium leading-tight group-hover:text-accent transition-colors">
|
||||
{resource.title}
|
||||
</h3>
|
||||
|
||||
{/* Excerpt */}
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{resource.excerpt}
|
||||
</p>
|
||||
|
||||
{/* Author */}
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-border">
|
||||
<ImageWithFallback
|
||||
src={resource.author.avatar}
|
||||
alt={resource.author.name}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-foreground text-sm">
|
||||
{resource.author.name}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-accent hover:text-accent-foreground hover:bg-accent/10 p-2 h-auto group-hover:translate-x-1 transition-transform"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateTo(`/insights/${resource.slug}`);
|
||||
}}
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResourceCards = () => {
|
||||
return (
|
||||
<section className="relative py-20 overflow-hidden">
|
||||
<div className="container mx-auto px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl lg:text-4xl font-semibold text-foreground mb-4">
|
||||
Insights for Founders & Product Leaders
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||
Learn from our experience building 200+ digital products. Practical insights, real case studies, and actionable strategies.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Resource Cards Grid */}
|
||||
<div className="grid lg:grid-cols-3 gap-8 mb-12 max-w-7xl mx-auto">
|
||||
{resources.map((resource, index) => (
|
||||
<ResourceCard key={resource.title} resource={resource} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center"
|
||||
>
|
||||
<Button className="bg-accent hover:bg-accent/90 text-accent-foreground border-0 rounded-lg px-6 py-3" onClick={() => navigateTo('/resources')}>
|
||||
View All Resources <ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
420
components/ScrollParallaxProcess.tsx
Normal file
420
components/ScrollParallaxProcess.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import { motion, useScroll, useTransform, useSpring } from "framer-motion";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { FileText, Figma, Code, Rocket, ArrowRight, Smartphone, Monitor, Palette, Database, Cloud, CheckCircle } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: "discovery",
|
||||
title: "Define the Scope",
|
||||
subtext: "We begin by understanding your vision, identifying key problems, and drafting a well-defined scope with clear goals and deliverables.",
|
||||
icon: FileText,
|
||||
visual: "workspace_parallax",
|
||||
color: "from-blue-500/20 to-cyan-500/20"
|
||||
},
|
||||
{
|
||||
id: "design",
|
||||
title: "Designing the Experience",
|
||||
subtext: "Our designers craft intuitive and stunning user interfaces in Figma, turning requirements into clickable, user-first prototypes.",
|
||||
icon: Figma,
|
||||
visual: "figma_canvas_animation",
|
||||
color: "from-purple-500/20 to-pink-500/20"
|
||||
},
|
||||
{
|
||||
id: "development",
|
||||
title: "Engineering the Solution",
|
||||
subtext: "We bring designs to life with clean, scalable code—choosing the right tech stack to ensure performance and longevity.",
|
||||
icon: Code,
|
||||
visual: "code_editor_animation",
|
||||
color: "from-green-500/20 to-emerald-500/20"
|
||||
},
|
||||
{
|
||||
id: "launch",
|
||||
title: "Launch & Beyond",
|
||||
subtext: "We ship your product with confidence—ensuring QA, deployment, and post-launch support to keep it growing.",
|
||||
icon: Rocket,
|
||||
visual: "launch_animation",
|
||||
color: "from-orange-500/20 to-red-500/20"
|
||||
}
|
||||
];
|
||||
|
||||
const WorkspaceVisual = ({ inView }: { inView: boolean }) => {
|
||||
return (
|
||||
<div className="relative w-full h-64 overflow-hidden rounded-lg bg-gradient-to-br from-blue-500/10 to-cyan-500/10 p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* Floating Documents */}
|
||||
<motion.div
|
||||
animate={inView ? { x: [-10, 10, -10], y: [-5, 5, -5] } : {}}
|
||||
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute top-4 left-4 w-12 h-16 bg-white/10 rounded shadow-lg flex items-center justify-center"
|
||||
>
|
||||
<FileText className="w-6 h-6 text-blue-400" />
|
||||
</motion.div>
|
||||
|
||||
{/* Sticky Notes */}
|
||||
<motion.div
|
||||
animate={inView ? { rotate: [-2, 2, -2] } : {}}
|
||||
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute top-16 right-8 w-16 h-16 bg-yellow-400/20 rounded-sm p-2"
|
||||
>
|
||||
<div className="w-full h-1 bg-yellow-400/40 rounded mb-1"></div>
|
||||
<div className="w-3/4 h-1 bg-yellow-400/30 rounded mb-1"></div>
|
||||
<div className="w-1/2 h-1 bg-yellow-400/20 rounded"></div>
|
||||
</motion.div>
|
||||
|
||||
{/* Pointer Highlighting */}
|
||||
<motion.div
|
||||
animate={inView ? { x: [0, 100, 0] } : {}}
|
||||
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut", delay: 1 }}
|
||||
className="absolute bottom-8 left-8 w-2 h-2 bg-accent rounded-full"
|
||||
>
|
||||
<div className="absolute -top-1 -left-1 w-4 h-4 border-2 border-accent rounded-full animate-pulse"></div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FigmaCanvasVisual = ({ inView }: { inView: boolean }) => {
|
||||
return (
|
||||
<div className="relative w-full h-64 overflow-hidden rounded-lg bg-gradient-to-br from-purple-500/10 to-pink-500/10 p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={inView ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* UI Frames */}
|
||||
<motion.div
|
||||
animate={inView ? { scale: [1, 1.05, 1] } : {}}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute top-4 left-4 w-20 h-32 bg-white/10 rounded-lg border border-purple-400/30 p-2"
|
||||
>
|
||||
<div className="w-full h-4 bg-purple-400/20 rounded mb-2"></div>
|
||||
<div className="space-y-1">
|
||||
<div className="w-full h-2 bg-purple-400/15 rounded"></div>
|
||||
<div className="w-3/4 h-2 bg-purple-400/10 rounded"></div>
|
||||
</div>
|
||||
<Smartphone className="absolute bottom-2 right-2 w-4 h-4 text-purple-400/50" />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
animate={inView ? { scale: [1, 1.03, 1] } : {}}
|
||||
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut", delay: 0.5 }}
|
||||
className="absolute top-4 right-4 w-24 h-16 bg-white/10 rounded-lg border border-pink-400/30 p-2"
|
||||
>
|
||||
<div className="w-full h-3 bg-pink-400/20 rounded mb-1"></div>
|
||||
<div className="w-2/3 h-2 bg-pink-400/15 rounded"></div>
|
||||
<Monitor className="absolute bottom-1 right-1 w-3 h-3 text-pink-400/50" />
|
||||
</motion.div>
|
||||
|
||||
{/* Design Tools */}
|
||||
<motion.div
|
||||
animate={inView ? { rotate: [0, 360] } : {}}
|
||||
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute bottom-8 left-8"
|
||||
>
|
||||
<Palette className="w-8 h-8 text-purple-400" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeEditorVisual = ({ inView }: { inView: boolean }) => {
|
||||
const [typedText, setTypedText] = useState("");
|
||||
const codeSnippet = "const app = () => {\n return <div>Hello World</div>\n}";
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
let i = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (i < codeSnippet.length) {
|
||||
setTypedText(codeSnippet.slice(0, i + 1));
|
||||
i++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
} else {
|
||||
setTypedText("");
|
||||
}
|
||||
}, [inView]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-64 overflow-hidden rounded-lg bg-gradient-to-br from-green-500/10 to-emerald-500/10 p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={inView ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="bg-gray-900/50 rounded-lg p-4 h-full"
|
||||
>
|
||||
{/* Code Editor Interface */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-3 h-3 bg-red-400 rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-yellow-400 rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-green-400 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
{/* Typed Code */}
|
||||
<pre className="text-green-400 text-sm font-mono">
|
||||
{typedText}
|
||||
<motion.span
|
||||
animate={{ opacity: [1, 0, 1] }}
|
||||
transition={{ duration: 1, repeat: Infinity }}
|
||||
className="bg-green-400 w-2 h-4 inline-block ml-1"
|
||||
/>
|
||||
</pre>
|
||||
|
||||
{/* Tech Stack Icons */}
|
||||
<div className="absolute bottom-4 right-4 flex gap-2">
|
||||
<motion.div
|
||||
animate={inView ? { y: [-2, 2, -2] } : {}}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<Database className="w-6 h-6 text-green-400" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={inView ? { y: [2, -2, 2] } : {}}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut", delay: 0.5 }}
|
||||
>
|
||||
<Cloud className="w-6 h-6 text-emerald-400" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LaunchVisual = ({ inView }: { inView: boolean }) => {
|
||||
return (
|
||||
<div className="relative w-full h-64 overflow-hidden rounded-lg bg-gradient-to-br from-orange-500/10 to-red-500/10 p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={inView ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="relative h-full"
|
||||
>
|
||||
{/* Rocket Animation */}
|
||||
<motion.div
|
||||
animate={inView ? { y: [-20, -40, -20], x: [0, 10, 0] } : {}}
|
||||
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute bottom-8 left-1/2 transform -translate-x-1/2"
|
||||
>
|
||||
<Rocket className="w-12 h-12 text-orange-400" />
|
||||
<motion.div
|
||||
animate={inView ? { scale: [0.8, 1.2, 0.8], opacity: [0.3, 0.7, 0.3] } : {}}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-8 h-8 bg-orange-400/30 rounded-full blur-sm"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Analytics Dashboard */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
className="absolute top-4 right-4 w-24 h-16 bg-white/10 rounded p-2"
|
||||
>
|
||||
<div className="flex items-end justify-between h-full">
|
||||
<motion.div
|
||||
animate={inView ? { height: ["40%", "60%", "40%"] } : {}}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="w-2 bg-orange-400/60 rounded-t"
|
||||
/>
|
||||
<motion.div
|
||||
animate={inView ? { height: ["60%", "80%", "60%"] } : {}}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut", delay: 0.3 }}
|
||||
className="w-2 bg-red-400/60 rounded-t"
|
||||
/>
|
||||
<motion.div
|
||||
animate={inView ? { height: ["30%", "70%", "30%"] } : {}}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut", delay: 0.6 }}
|
||||
className="w-2 bg-yellow-400/60 rounded-t"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Success Indicators */}
|
||||
<motion.div
|
||||
animate={inView ? { scale: [0, 1, 0] } : {}}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut", delay: 1 }}
|
||||
className="absolute top-8 left-8"
|
||||
>
|
||||
<CheckCircle className="w-8 h-8 text-green-400" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProcessStep = ({ step, index, inView }: { step: typeof steps[0]; index: number; inView: boolean }) => {
|
||||
const Icon = step.icon;
|
||||
|
||||
const renderVisual = () => {
|
||||
switch (step.visual) {
|
||||
case "workspace_parallax":
|
||||
return <WorkspaceVisual inView={inView} />;
|
||||
case "figma_canvas_animation":
|
||||
return <FigmaCanvasVisual inView={inView} />;
|
||||
case "code_editor_animation":
|
||||
return <CodeEditorVisual inView={inView} />;
|
||||
case "launch_animation":
|
||||
return <LaunchVisual inView={inView} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isEven = index % 2 === 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: index * 0.2 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
className={`grid lg:grid-cols-2 gap-12 items-center ${!isEven ? "lg:flex-row-reverse" : ""}`}
|
||||
>
|
||||
{/* Content */}
|
||||
<div className={`space-y-6 ${!isEven ? "lg:order-2" : ""}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
className="w-16 h-16 bg-accent/10 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<Icon className="w-8 h-8 text-accent" />
|
||||
</motion.div>
|
||||
<div className="text-sm text-accent font-medium">
|
||||
Step {index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.h3
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="text-3xl lg:text-4xl font-semibold text-foreground"
|
||||
>
|
||||
{step.title}
|
||||
</motion.h3>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
className="text-muted-foreground text-lg leading-relaxed"
|
||||
>
|
||||
{step.subtext}
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Visual */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: isEven ? 50 : -50 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
className={`${!isEven ? "lg:order-1" : ""}`}
|
||||
>
|
||||
{renderVisual()}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScrollParallaxProcess = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ["start end", "end start"]
|
||||
});
|
||||
|
||||
const springScrollProgress = useSpring(scrollYProgress, {
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
restDelta: 0.001
|
||||
});
|
||||
|
||||
const y1 = useTransform(springScrollProgress, [0, 1], [0, -100]);
|
||||
const y2 = useTransform(springScrollProgress, [0, 1], [0, -200]);
|
||||
const y3 = useTransform(springScrollProgress, [0, 1], [0, -50]);
|
||||
|
||||
return (
|
||||
<section ref={containerRef} className="relative py-20 overflow-hidden">
|
||||
<GridPattern strokeDasharray="4 2" />
|
||||
|
||||
{/* Parallax Background Elements */}
|
||||
<motion.div
|
||||
style={{ y: y1 }}
|
||||
className="absolute top-20 left-10 w-32 h-32 bg-accent/5 rounded-full blur-3xl"
|
||||
/>
|
||||
<motion.div
|
||||
style={{ y: y2 }}
|
||||
className="absolute bottom-20 right-10 w-48 h-48 bg-blue-500/5 rounded-full blur-3xl"
|
||||
/>
|
||||
<motion.div
|
||||
style={{ y: y3 }}
|
||||
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-purple-500/5 rounded-full blur-3xl"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 container mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-semibold text-foreground mb-6">
|
||||
How We Turn an Idea into a{" "}
|
||||
<span className="text-accent">Scalable Product</span>
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xl max-w-3xl mx-auto leading-relaxed">
|
||||
Our proven process transforms your vision into reality through strategic planning,
|
||||
thoughtful design, and expert engineering—every step of the way.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-32">
|
||||
{steps.map((step, index) => (
|
||||
<ProcessStep
|
||||
key={step.id}
|
||||
step={step}
|
||||
index={index}
|
||||
inView={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Final CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mt-20"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-accent hover:bg-accent/90 text-accent-foreground px-8 py-4 rounded-lg group"
|
||||
>
|
||||
Let's Build Yours
|
||||
<motion.div
|
||||
animate={{ x: [0, 5, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
135
components/ServicesGrid.tsx
Normal file
135
components/ServicesGrid.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Smartphone, Globe, Palette, Brain, RefreshCw, Users } from "lucide-react";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
import { navigateTo } from "../App";
|
||||
|
||||
const services = [
|
||||
{
|
||||
title: "Mobile App Development",
|
||||
icon: Smartphone,
|
||||
description: "Native and cross-platform mobile solutions",
|
||||
href: "/services/mobile-app-development"
|
||||
},
|
||||
{
|
||||
title: "Web & SaaS Engineering",
|
||||
icon: Globe,
|
||||
description: "Scalable web applications and SaaS platforms"
|
||||
},
|
||||
{
|
||||
title: "UI/UX & Prototyping",
|
||||
icon: Palette,
|
||||
description: "User-centered design and interactive prototypes"
|
||||
},
|
||||
{
|
||||
title: "AI & Data Intelligence",
|
||||
icon: Brain,
|
||||
description: "Machine learning and data-driven solutions"
|
||||
},
|
||||
{
|
||||
title: "Product Modernization",
|
||||
icon: RefreshCw,
|
||||
description: "Legacy system upgrades and optimization"
|
||||
},
|
||||
{
|
||||
title: "Dedicated Development Teams",
|
||||
icon: Users,
|
||||
description: "Extended teams and staff augmentation"
|
||||
},
|
||||
];
|
||||
|
||||
const ServiceCard = ({ service, index }: { service: typeof services[0]; index: number }) => {
|
||||
const Icon = service.icon;
|
||||
|
||||
const handleClick = () => {
|
||||
if (service.href) {
|
||||
navigateTo(service.href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
y: -8
|
||||
}}
|
||||
className={`group relative p-8 rounded-2xl bg-white/5 backdrop-blur-sm border border-white/10 hover:border-[#E5195E] transition-all duration-500 overflow-hidden ${
|
||||
service.href ? 'cursor-pointer' : 'cursor-default'
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<div className="mb-6">
|
||||
{/* Icon container with glassmorphism effect */}
|
||||
<div className="relative w-16 h-16 rounded-xl bg-gradient-to-br from-[#E5195E]/20 to-white/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-all duration-500 backdrop-blur-sm border border-white/10">
|
||||
{/* Icon glow effect */}
|
||||
<div className="absolute inset-0 rounded-xl bg-[#E5195E]/20 blur-xl opacity-0 group-hover:opacity-60 transition-opacity duration-500" />
|
||||
<Icon className="relative w-8 h-8 text-[#E5195E] transition-colors duration-300" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold text-white mb-3 group-hover:text-white transition-colors duration-300">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-[#CCCCCC] text-sm leading-relaxed group-hover:text-white/90 transition-colors duration-300">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow indicator - only show for clickable services */}
|
||||
{service.href && (
|
||||
<div className="flex items-center justify-end opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-4 group-hover:translate-x-0">
|
||||
<div className="w-8 h-8 rounded-full bg-[#E5195E]/20 backdrop-blur-sm flex items-center justify-center border border-[#E5195E]/30">
|
||||
<svg
|
||||
className="w-4 h-4 text-[#E5195E]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover overlay for clickable services */}
|
||||
{service.href && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#E5195E]/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ServicesGrid = () => {
|
||||
return (
|
||||
<section className="relative py-20 bg-[#0E0E0E] overflow-hidden">
|
||||
<GridPattern strokeDasharray="4 2" />
|
||||
|
||||
<div className="relative z-10 container mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl lg:text-4xl font-semibold text-white mb-4">
|
||||
What We Do
|
||||
</h2>
|
||||
<p className="text-[#CCCCCC] text-lg max-w-2xl mx-auto">
|
||||
We deliver comprehensive digital solutions that transform ideas into powerful, scalable applications
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{services.map((service, index) => (
|
||||
<ServiceCard key={service.title} service={service} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
125
components/ServicesSection.tsx
Normal file
125
components/ServicesSection.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
export function ServicesSection() {
|
||||
const services = [
|
||||
{
|
||||
title: "Mobile App Development",
|
||||
description: "Native & cross-platform apps with pixel-perfect UIs and seamless user experiences.",
|
||||
icon: (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
),
|
||||
gradient: "from-[#E5195E]/20 to-purple-500/20"
|
||||
},
|
||||
{
|
||||
title: "Web Platforms",
|
||||
description: "Scalable, secure, and SEO-optimized web applications built for performance.",
|
||||
icon: (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
||||
/>
|
||||
),
|
||||
gradient: "from-[#E5195E]/20 to-blue-500/20"
|
||||
},
|
||||
{
|
||||
title: "AI & ML Solutions",
|
||||
description: "Intelligent features powered by cutting-edge algorithms and machine learning.",
|
||||
icon: (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
),
|
||||
gradient: "from-[#E5195E]/20 to-cyan-500/20"
|
||||
},
|
||||
{
|
||||
title: "DevOps & Cloud",
|
||||
description: "CI/CD pipelines and managed cloud infrastructure for seamless deployment.",
|
||||
icon: (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||
/>
|
||||
),
|
||||
gradient: "from-[#E5195E]/20 to-orange-500/20"
|
||||
},
|
||||
{
|
||||
title: "Product Design",
|
||||
description: "Human-centered UX with delightful micro-interactions and intuitive interfaces.",
|
||||
icon: (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4h4a2 2 0 002-2V5z"
|
||||
/>
|
||||
),
|
||||
gradient: "from-[#E5195E]/20 to-pink-500/20"
|
||||
},
|
||||
{
|
||||
title: "Security & Compliance",
|
||||
description: "Pen-testing, auditing, and regulatory alignment for enterprise-grade security.",
|
||||
icon: (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
),
|
||||
gradient: "from-[#E5195E]/20 to-green-500/20"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="services" className="border-t border-gray-800">
|
||||
<div className="container mx-auto px-6 lg:px-8 py-24">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl sm:text-4xl font-semibold tracking-tight text-white">What We Do</h2>
|
||||
<p className="mt-4 text-gray-400 max-w-2xl mx-auto">
|
||||
End-to-end solutions for every stage of your product lifecycle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Services Grid - Wider container and larger cards */}
|
||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-8xl mx-auto">
|
||||
{services.map((service, index) => (
|
||||
<div key={index} className="group relative p-10 border border-gray-800/50 hover:border-gray-700/70 transition-all duration-300 rounded-[10px] backdrop-blur-sm hover:backdrop-blur-md">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-900/20 via-gray-800/10 to-gray-900/20 rounded-[10px] opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
{/* Glassmorphism Icon */}
|
||||
<div className={`relative mb-8 w-20 h-20 rounded-2xl bg-gradient-to-br from-white/10 to-white/5 backdrop-blur-xl border border-white/20 flex items-center justify-center group-hover:scale-110 transition-transform duration-300`}>
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${service.gradient} rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300`}></div>
|
||||
<svg
|
||||
className="w-10 h-10 text-white/90 relative z-10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{service.icon}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold text-white tracking-tight mb-4">
|
||||
<span className="text-[#E5195E]">{service.title.split(' ')[0]}</span>
|
||||
{service.title.split(' ').slice(1).join(' ') && ` ${service.title.split(' ').slice(1).join(' ')}`}
|
||||
</h3>
|
||||
<p className="text-gray-400 leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
99
components/SplineFallback.tsx
Normal file
99
components/SplineFallback.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
export function SplineFallback() {
|
||||
return (
|
||||
<div className="w-full h-full bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-sm rounded-xl flex items-center justify-center overflow-hidden relative">
|
||||
{/* Animated background gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#E5195E]/10 to-purple-500/10 animate-pulse"></div>
|
||||
|
||||
{/* Grid pattern overlay */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<svg className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="white" strokeWidth="1"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Main 3D scene */}
|
||||
<div className="relative z-10 w-full h-full flex items-center justify-center">
|
||||
{/* Central 3D composition */}
|
||||
<div className="relative">
|
||||
{/* Main floating cube with nested elements */}
|
||||
<div className="w-40 h-40 relative transform-gpu">
|
||||
{/* Outer cube */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#E5195E]/30 to-purple-500/30 rounded-3xl transform rotate-12 animate-[float_4s_ease-in-out_infinite] backdrop-blur-sm border border-white/20 shadow-2xl">
|
||||
{/* Middle cube */}
|
||||
<div className="absolute inset-6 bg-gradient-to-br from-blue-500/40 to-cyan-500/40 rounded-2xl transform -rotate-12 animate-[float_3s_ease-in-out_infinite_0.5s] backdrop-blur-sm border border-white/10">
|
||||
{/* Inner cube */}
|
||||
<div className="absolute inset-6 bg-gradient-to-br from-green-500/50 to-emerald-500/50 rounded-xl transform rotate-6 animate-[float_2s_ease-in-out_infinite_1s] backdrop-blur-sm border border-white/5">
|
||||
{/* Core element */}
|
||||
<div className="absolute inset-4 bg-gradient-to-br from-orange-500/60 to-yellow-500/60 rounded-lg animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orbiting satellites */}
|
||||
<div className="absolute top-0 left-0 w-full h-full">
|
||||
{/* Satellite 1 */}
|
||||
<div className="absolute -top-8 -right-8 w-8 h-8 bg-gradient-to-r from-[#E5195E] to-pink-500 rounded-xl transform rotate-45 animate-[orbit_8s_linear_infinite] shadow-lg">
|
||||
<div className="absolute inset-1 bg-white/20 rounded-lg animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
{/* Satellite 2 */}
|
||||
<div className="absolute -bottom-6 -left-8 w-6 h-6 bg-gradient-to-r from-blue-500 to-cyan-400 rounded-lg transform -rotate-12 animate-[orbit_6s_linear_infinite_reverse] shadow-lg">
|
||||
<div className="absolute inset-1 bg-white/30 rounded-sm animate-pulse delay-500"></div>
|
||||
</div>
|
||||
|
||||
{/* Satellite 3 */}
|
||||
<div className="absolute top-1/2 -left-16 w-5 h-5 bg-gradient-to-r from-green-400 to-emerald-400 rounded-full animate-[orbit_10s_linear_infinite] shadow-lg">
|
||||
<div className="absolute inset-1 bg-white/40 rounded-full animate-pulse delay-1000"></div>
|
||||
</div>
|
||||
|
||||
{/* Satellite 4 */}
|
||||
<div className="absolute top-1/4 -right-14 w-7 h-7 bg-gradient-to-r from-purple-500 to-violet-400 rounded-2xl transform rotate-30 animate-[orbit_7s_linear_infinite_reverse] shadow-lg">
|
||||
<div className="absolute inset-1.5 bg-white/25 rounded-xl animate-pulse delay-1500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating particles */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Particle 1 */}
|
||||
<div className="absolute top-1/6 left-1/5 w-2 h-2 bg-white/60 rounded-full animate-[float_3s_ease-in-out_infinite] shadow-sm"></div>
|
||||
|
||||
{/* Particle 2 */}
|
||||
<div className="absolute top-2/3 left-4/5 w-1.5 h-1.5 bg-[#E5195E]/80 rounded-full animate-[float_4s_ease-in-out_infinite_0.8s] shadow-sm"></div>
|
||||
|
||||
{/* Particle 3 */}
|
||||
<div className="absolute top-1/2 left-1/8 w-1 h-1 bg-blue-400/70 rounded-full animate-[float_2.5s_ease-in-out_infinite_1.2s] shadow-sm"></div>
|
||||
|
||||
{/* Particle 4 */}
|
||||
<div className="absolute top-1/4 right-1/6 w-1.5 h-1.5 bg-green-400/60 rounded-full animate-[float_3.5s_ease-in-out_infinite_1.8s] shadow-sm"></div>
|
||||
|
||||
{/* Particle 5 */}
|
||||
<div className="absolute bottom-1/4 left-1/3 w-1 h-1 bg-purple-400/50 rounded-full animate-[float_4.5s_ease-in-out_infinite_2.2s] shadow-sm"></div>
|
||||
</div>
|
||||
|
||||
{/* Light rays */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/2 left-1/2 w-1 h-32 bg-gradient-to-t from-transparent via-white/10 to-transparent transform -translate-x-1/2 -translate-y-1/2 rotate-45 animate-[rotate_20s_linear_infinite]"></div>
|
||||
<div className="absolute top-1/2 left-1/2 w-1 h-24 bg-gradient-to-t from-transparent via-[#E5195E]/10 to-transparent transform -translate-x-1/2 -translate-y-1/2 rotate-135 animate-[rotate_15s_linear_infinite_reverse]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="absolute bottom-6 left-6 flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs text-white/70 font-medium">Interactive 3D Experience</span>
|
||||
</div>
|
||||
|
||||
{/* Tech badge */}
|
||||
<div className="absolute top-6 right-6 px-3 py-1 bg-black/40 backdrop-blur-sm rounded-full border border-white/10">
|
||||
<span className="text-xs text-white/80 font-medium">AI Powered</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
components/SplineViewer.tsx
Normal file
7
components/SplineViewer.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
// This file is no longer needed as we're using the official Spline component
|
||||
// from '@splinetool/react-spline/next' directly in the HeroSection component.
|
||||
// Keeping this file empty to avoid any import errors until references are cleaned up.
|
||||
|
||||
export function SplineViewer() {
|
||||
return null;
|
||||
}
|
||||
118
components/SplitCallToAction.tsx
Normal file
118
components/SplitCallToAction.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Phone, Clock, Zap, Calendar, MessageSquare } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
import { navigateTo } from "../App";
|
||||
|
||||
export const SplitCallToAction = () => {
|
||||
return (
|
||||
<section className="relative py-20 overflow-hidden">
|
||||
<GridPattern strokeDasharray="4 2" />
|
||||
|
||||
<div className="relative z-10 container mx-auto px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center max-w-6xl mx-auto">
|
||||
{/* Left Content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-5xl font-semibold text-foreground mb-6 whitespace-nowrap">
|
||||
Ready to Build with WDI?
|
||||
</h2>
|
||||
<p className="text-xl text-muted-foreground leading-relaxed mb-8">
|
||||
Schedule a no-commitment discovery call with our consulting team. Let's discuss your vision and create a roadmap to success.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-white/10 backdrop-blur-sm rounded-lg border border-white/20 flex items-center justify-center">
|
||||
<MessageSquare className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-foreground font-medium">Free Consultation</div>
|
||||
<div className="text-muted-foreground text-sm">No sales pitch, just honest advice</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-white/10 backdrop-blur-sm rounded-lg border border-white/20 flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-foreground font-medium">Flexible Scheduling</div>
|
||||
<div className="text-muted-foreground text-sm">Available across all time zones</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-white/10 backdrop-blur-sm rounded-lg border border-white/20 flex items-center justify-center">
|
||||
<Zap className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-foreground font-medium">Quick Response</div>
|
||||
<div className="text-muted-foreground text-sm">We'll get back to you within 2 hours</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Right CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="bg-card/50 backdrop-blur-sm rounded-lg p-8 border border-border">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-white/10 backdrop-blur-sm rounded-full border border-white/20 flex items-center justify-center">
|
||||
<Calendar className="w-10 h-10 text-accent" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-foreground mb-2">
|
||||
Book a Discovery Call
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Let's discuss your project and explore how we can help you succeed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-accent hover:bg-accent/90 text-accent-foreground py-4 text-lg border-0 rounded-lg"
|
||||
onClick={() => navigateTo('/contact')}
|
||||
>
|
||||
<Phone className="w-5 h-5 mr-2" />
|
||||
Schedule Free Call
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-border">
|
||||
<div className="grid grid-cols-3 gap-4 text-center text-sm">
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">200+</div>
|
||||
<div className="text-muted-foreground">Projects</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">25+</div>
|
||||
<div className="text-muted-foreground">Years</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">15+</div>
|
||||
<div className="text-muted-foreground">Countries</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
94
components/StepsIllustrated.tsx
Normal file
94
components/StepsIllustrated.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { FileText, Palette, Code, Rocket } from "lucide-react";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: "Define Scope",
|
||||
description: "We analyze your requirements and create a detailed project roadmap with clear timelines.",
|
||||
icon: FileText,
|
||||
color: "from-blue-500 to-cyan-500"
|
||||
},
|
||||
{
|
||||
title: "Design UI/UX",
|
||||
description: "Our designers create intuitive, user-centered interfaces that align with your brand.",
|
||||
icon: Palette,
|
||||
color: "from-purple-500 to-pink-500"
|
||||
},
|
||||
{
|
||||
title: "Develop with Agile Sprints",
|
||||
description: "We build your product using agile methodology with regular updates and feedback loops.",
|
||||
icon: Code,
|
||||
color: "from-green-500 to-emerald-500"
|
||||
},
|
||||
{
|
||||
title: "Test, Launch & Scale",
|
||||
description: "Comprehensive testing, smooth deployment, and ongoing support for continuous growth.",
|
||||
icon: Rocket,
|
||||
color: "from-[#E5195E] to-orange-500"
|
||||
}
|
||||
];
|
||||
|
||||
const StepCard = ({ step, index }: { step: typeof steps[0]; index: number }) => {
|
||||
const Icon = step.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="relative group"
|
||||
>
|
||||
{/* Connection Line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="hidden lg:block absolute top-16 left-full w-full h-0.5 bg-gradient-to-r from-white/20 to-transparent z-0" />
|
||||
)}
|
||||
|
||||
<div className="relative z-10 text-center">
|
||||
<div className="mb-6">
|
||||
<div className={`w-20 h-20 mx-auto rounded-2xl bg-gradient-to-br ${step.color} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300`}>
|
||||
<Icon className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<div className="w-8 h-8 mx-auto rounded-full bg-[#E5195E] flex items-center justify-center text-white font-bold text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold text-white mb-4">{step.title}</h3>
|
||||
<p className="text-[#CCCCCC] leading-relaxed max-w-sm mx-auto">{step.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const StepsIllustrated = () => {
|
||||
return (
|
||||
<section className="relative py-20 bg-[#121212] overflow-hidden">
|
||||
<GridPattern strokeDasharray="4 2" />
|
||||
|
||||
<div className="relative z-10 container mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl lg:text-4xl font-semibold text-white mb-4">
|
||||
How We Turn Your Idea Into a Scalable Product
|
||||
</h2>
|
||||
<p className="text-[#CCCCCC] text-lg max-w-2xl mx-auto">
|
||||
Our proven 4-step process ensures your project is delivered on time, on budget, and exceeds expectations.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid lg:grid-cols-4 gap-12 max-w-6xl mx-auto">
|
||||
{steps.map((step, index) => (
|
||||
<StepCard key={step.title} step={step} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
72
components/WhyChooseWDI.tsx
Normal file
72
components/WhyChooseWDI.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Wrench, Shield, Zap } from "lucide-react";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Wrench,
|
||||
title: "24+ Years of Product Engineering",
|
||||
description: "Deep expertise in building scalable, production-ready solutions"
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "100% Project Ownership & IP Transfer",
|
||||
description: "Complete intellectual property rights and full project ownership"
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Agile, Transparent, and Outcome-Driven",
|
||||
description: "Fast delivery with clear communication and measurable results"
|
||||
},
|
||||
];
|
||||
|
||||
const FeatureCard = ({ feature, index }: { feature: typeof features[0]; index: number }) => {
|
||||
const Icon = feature.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{ y: -5 }}
|
||||
className="text-center group"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="w-20 h-20 mx-auto rounded-2xl bg-[#E5195E]/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Icon className="w-10 h-10 text-[#E5195E]" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-4">{feature.title}</h3>
|
||||
<p className="text-[#CCCCCC] leading-relaxed max-w-sm mx-auto">{feature.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WhyChooseWDI = () => {
|
||||
return (
|
||||
<section className="relative py-20 bg-[#0E0E0E] overflow-hidden">
|
||||
<GridPattern strokeDasharray="4 2" />
|
||||
|
||||
<div className="relative z-10 container mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl lg:text-4xl font-semibold text-white mb-4">
|
||||
Why Leading Startups Choose WDI
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-12 max-w-6xl mx-auto">
|
||||
{features.map((feature, index) => (
|
||||
<FeatureCard key={feature.title} feature={feature} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
27
components/figma/ImageWithFallback.tsx
Normal file
27
components/figma/ImageWithFallback.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const ERROR_IMG_SRC =
|
||||
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
|
||||
|
||||
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||
const [didError, setDidError] = useState(false)
|
||||
|
||||
const handleError = () => {
|
||||
setDidError(true)
|
||||
}
|
||||
|
||||
const { src, alt, style, className, ...rest } = props
|
||||
|
||||
return didError ? (
|
||||
<div
|
||||
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
|
||||
style={style}
|
||||
>
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
|
||||
)
|
||||
}
|
||||
55
components/ui/accordion.tsx
Normal file
55
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "./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}
|
||||
</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 }
|
||||
157
components/ui/alert-dialog.tsx
Normal file
157
components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { buttonVariants } from "./button";
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
66
components/ui/alert.tsx
Normal file
66
components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
11
components/ui/aspect-ratio.tsx
Normal file
11
components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
|
||||
}
|
||||
|
||||
export { AspectRatio };
|
||||
53
components/ui/avatar.tsx
Normal file
53
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
46
components/ui/badge.tsx
Normal file
46
components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
41
components/ui/border-beam.tsx
Normal file
41
components/ui/border-beam.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { cn } from "./utils";
|
||||
|
||||
interface BorderBeamProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
duration?: number;
|
||||
borderWidth?: number;
|
||||
colorFrom?: string;
|
||||
colorTo?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export const BorderBeam = ({
|
||||
className,
|
||||
size = 200,
|
||||
duration = 8,
|
||||
borderWidth = 2,
|
||||
colorFrom = "#E5195E",
|
||||
colorTo = "#ffffff",
|
||||
delay = 0,
|
||||
}: BorderBeamProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-[inherit]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-[inherit] opacity-75"
|
||||
style={{
|
||||
background: `conic-gradient(from 0deg, transparent, ${colorFrom}, ${colorTo}, transparent)`,
|
||||
WebkitMask: `radial-gradient(farthest-side at center, transparent calc(100% - ${borderWidth}px), white calc(100% - ${borderWidth}px))`,
|
||||
mask: `radial-gradient(farthest-side at center, transparent calc(100% - ${borderWidth}px), white calc(100% - ${borderWidth}px))`,
|
||||
animation: `border-beam ${duration}s linear infinite`,
|
||||
animationDelay: `${delay}s`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
109
components/ui/breadcrumb.tsx
Normal file
109
components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
57
components/ui/button.tsx
Normal file
57
components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "./utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"btn-elevation inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "btn-primary-wdi",
|
||||
destructive: "btn-destructive-wdi",
|
||||
outline: "btn-outline-wdi",
|
||||
secondary: "btn-secondary-wdi",
|
||||
ghost: "btn-ghost-wdi",
|
||||
link: "btn-link-wdi",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2 text-sm",
|
||||
sm: "btn-sm h-8 rounded-md px-3 text-xs",
|
||||
lg: "btn-lg h-11 rounded-md px-8",
|
||||
xl: "btn-xl h-12 rounded-md px-10",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
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, children, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
75
components/ui/calendar.tsx
Normal file
75
components/ui/calendar.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { buttonVariants } from "./button";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker>) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row gap-2",
|
||||
month: "flex flex-col gap-4",
|
||||
caption: "flex justify-center pt-1 relative items-center w-full",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "flex items-center gap-1",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"size-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-x-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: cn(
|
||||
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||
props.mode === "range"
|
||||
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||
: "[&:has([aria-selected])]:rounded-md",
|
||||
),
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"size-8 p-0 font-normal aria-selected:opacity-100",
|
||||
),
|
||||
day_range_start:
|
||||
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||
day_range_end:
|
||||
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||
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 aria-selected:text-muted-foreground",
|
||||
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: ({ className, ...props }) => (
|
||||
<ChevronLeft className={cn("size-4", className)} {...props} />
|
||||
),
|
||||
IconRight: ({ className, ...props }) => (
|
||||
<ChevronRight className={cn("size-4", className)} {...props} />
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar };
|
||||
92
components/ui/card.tsx
Normal file
92
components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<h4
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6 [&:last-child]:pb-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
241
components/ui/carousel.tsx
Normal file
241
components/ui/carousel.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { Button } from "./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;
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
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
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
353
components/ui/chart.tsx
Normal file
353
components/ui/chart.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border bg-input-background dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
33
components/ui/collapsible.tsx
Normal file
33
components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
177
components/ui/command.tsx
Normal file
177
components/ui/command.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./dialog";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[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>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
252
components/ui/context-menu.tsx
Normal file
252
components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
135
components/ui/dialog.tsx
Normal file
135
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
132
components/ui/drawer.tsx
Normal file
132
components/ui/drawer.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
257
components/ui/dropdown-menu.tsx
Normal file
257
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
168
components/ui/form.tsx
Normal file
168
components/ui/form.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { Label } from "./label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
44
components/ui/hover-card.tsx
Normal file
44
components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
77
components/ui/input-otp.tsx
Normal file
77
components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { MinusIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-disabled:opacity-50",
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm bg-input-background transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
276
components/ui/menubar.tsx
Normal file
276
components/ui/menubar.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Menubar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||
return (
|
||||
<MenubarPrimitive.Root
|
||||
data-slot="menubar"
|
||||
className={cn(
|
||||
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||
return (
|
||||
<MenubarPrimitive.Trigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||
return (
|
||||
<MenubarPortal>
|
||||
<MenubarPrimitive.Content
|
||||
data-slot="menubar-content"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Item
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Label
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||
return (
|
||||
<MenubarPrimitive.Separator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||
return (
|
||||
<MenubarPrimitive.SubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
};
|
||||
168
components/ui/navigation-menu.tsx
Normal file
168
components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"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 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center",
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
||||
127
components/ui/pagination.tsx
Normal file
127
components/ui/pagination.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { Button, buttonVariants } from "./button";
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />;
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">;
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
};
|
||||
48
components/ui/popover.tsx
Normal file
48
components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
31
components/ui/progress.tsx
Normal file
31
components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
45
components/ui/radio-group.tsx
Normal file
45
components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
56
components/ui/resizable.tsx
Normal file
56
components/ui/resizable.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
189
components/ui/select.tsx
Normal file
189
components/ui/select.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
27
components/ui/separator.tsx
Normal file
27
components/ui/separator.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
139
components/ui/sheet.tsx
Normal file
139
components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
29
components/ui/shimmer-button.tsx
Normal file
29
components/ui/shimmer-button.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { cn } from "./utils";
|
||||
|
||||
interface ShimmerButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ShimmerButton = React.forwardRef<HTMLButtonElement, ShimmerButtonProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center gap-2 rounded-md bg-accent px-6 py-3 text-sm font-medium text-accent-foreground transition-all duration-300 hover:bg-accent/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent whitespace-nowrap overflow-hidden",
|
||||
"before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ShimmerButton.displayName = "ShimmerButton";
|
||||
|
||||
export { ShimmerButton };
|
||||
726
components/ui/sidebar.tsx
Normal file
726
components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
|
||||
import { useIsMobile } from "./use-mobile";
|
||||
import { cn } from "./utils";
|
||||
import { Button } from "./button";
|
||||
import { Input } from "./input";
|
||||
import { Separator } from "./separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "./sheet";
|
||||
import { Skeleton } from "./skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
63
components/ui/slider.tsx
Normal file
63
components/ui/slider.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max],
|
||||
);
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-4 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
25
components/ui/sonner.tsx
Normal file
25
components/ui/sonner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
31
components/ui/switch.tsx
Normal file
31
components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-background focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-card dark:data-[state=unchecked]:bg-card-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
116
components/ui/table.tsx
Normal file
116
components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
66
components/ui/tabs.tsx
Normal file
66
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-xl p-[3px] flex",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-card dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"resize-none border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
73
components/ui/toggle-group.tsx
Normal file
73
components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { toggleVariants } from "./toggle";
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
47
components/ui/toggle.tsx
Normal file
47
components/ui/toggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"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 "./utils";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
61
components/ui/tooltip.tsx
Normal file
61
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
21
components/ui/use-mobile.ts
Normal file
21
components/ui/use-mobile.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
6
components/ui/utils.ts
Normal file
6
components/ui/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user