Spaces:
Sleeping
Sleeping
Initial commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +45 -0
- app/(public)/about/page.tsx +84 -0
- app/(public)/blog/[slug]/page.tsx +280 -0
- app/(public)/blog/page.tsx +99 -0
- app/(public)/contact/page.tsx +85 -0
- app/(public)/layout.tsx +16 -0
- app/(public)/page.tsx +19 -0
- app/(public)/portfolio/[slug]/page.tsx +156 -0
- app/(public)/portfolio/page.tsx +50 -0
- app/(public)/quote/page.tsx +524 -0
- app/(public)/services/page.tsx +33 -0
- app/admin/blog/[id]/page.tsx +66 -0
- app/admin/blog/new/page.tsx +17 -0
- app/admin/blog/page.tsx +179 -0
- app/admin/dashboard/page.tsx +78 -0
- app/admin/forgot-password/page.tsx +80 -0
- app/admin/inquiries/[id]/page.tsx +266 -0
- app/admin/inquiries/page.tsx +150 -0
- app/admin/login/page.tsx +142 -0
- app/admin/page.tsx +5 -0
- app/admin/portfolio/page.tsx +478 -0
- app/admin/posts/page.tsx +18 -0
- app/admin/reset-password/page.tsx +130 -0
- app/admin/settings/page.tsx +20 -0
- app/admin/testimonials/page.tsx +395 -0
- app/api/auth/[...nextauth]/route.ts +2 -0
- app/favicon.ico +0 -0
- app/globals.css +1494 -0
- app/layout.tsx +81 -0
- app/unauthorized/page.tsx +31 -0
- components.json +23 -0
- components/admin/BlogForm.tsx +281 -0
- components/auth/RoleGuard.tsx +46 -0
- components/home/CTASection.tsx +231 -0
- components/home/FeaturedPortfolio.tsx +252 -0
- components/home/HeroSection.tsx +263 -0
- components/home/ServicesOverview.tsx +256 -0
- components/home/TestimonialsCarousel.tsx +328 -0
- components/home/TrustIndicators.tsx +128 -0
- components/layout/Footer.tsx +205 -0
- components/layout/Header.tsx +185 -0
- components/mode-toggle.tsx +40 -0
- components/portfolio/ProjectGallery.tsx +106 -0
- components/services/AddOnServices.tsx +69 -0
- components/services/FAQSection.tsx +111 -0
- components/services/PackageComparison.tsx +124 -0
- components/services/ProcessTimeline.tsx +82 -0
- components/services/ServiceDescriptions.tsx +87 -0
- components/theme-provider.tsx +11 -0
- components/ui/accordion.tsx +66 -0
.gitignore
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
.env.local
|
| 36 |
+
.env.development.local
|
| 37 |
+
.env.test.local
|
| 38 |
+
.env.production.local
|
| 39 |
+
|
| 40 |
+
# vercel
|
| 41 |
+
.vercel
|
| 42 |
+
|
| 43 |
+
# typescript
|
| 44 |
+
*.tsbuildinfo
|
| 45 |
+
next-env.d.ts
|
app/(public)/about/page.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Users, Target, Award, Zap } from "lucide-react"
|
| 2 |
+
|
| 3 |
+
const values = [
|
| 4 |
+
{
|
| 5 |
+
icon: Target,
|
| 6 |
+
title: "Mission-Driven",
|
| 7 |
+
description: "Every project starts with understanding your goals. We align our expertise with your vision to deliver measurable results.",
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
icon: Users,
|
| 11 |
+
title: "Collaborative",
|
| 12 |
+
description: "We believe the best products are built together. You're involved at every step — from strategy to launch.",
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
icon: Award,
|
| 16 |
+
title: "Quality Obsessed",
|
| 17 |
+
description: "We don't cut corners. Every pixel, every line of code, every interaction is crafted to the highest standard.",
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
icon: Zap,
|
| 21 |
+
title: "Future-Ready",
|
| 22 |
+
description: "We build with tomorrow in mind — scalable architectures, modern frameworks, and forward-thinking design.",
|
| 23 |
+
},
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
export default function AboutPage() {
|
| 27 |
+
return (
|
| 28 |
+
<div className="py-20">
|
| 29 |
+
<div className="container">
|
| 30 |
+
{/* Hero */}
|
| 31 |
+
<div className="animate-fade-in-up text-center mb-20 max-w-3xl mx-auto">
|
| 32 |
+
<p className="text-sm font-semibold text-primary uppercase tracking-[0.15em] mb-3">About Us</p>
|
| 33 |
+
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl mb-6">
|
| 34 |
+
A small team with{" "}
|
| 35 |
+
<span className="text-primary">big ambitions</span>
|
| 36 |
+
</h1>
|
| 37 |
+
<p className="text-lg text-muted-foreground leading-relaxed">
|
| 38 |
+
We're a passionate team of designers, developers, and strategists who love turning ideas into
|
| 39 |
+
exceptional digital products. Founded with a simple belief: great software should be accessible to everyone.
|
| 40 |
+
</p>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
{/* Stats */}
|
| 44 |
+
<div className="animate-fade-in-up animate-delay-2 grid grid-cols-2 md:grid-cols-4 gap-8 mb-20">
|
| 45 |
+
{[
|
| 46 |
+
{ value: "5+", label: "Years" },
|
| 47 |
+
{ value: "50+", label: "Projects" },
|
| 48 |
+
{ value: "30+", label: "Happy Clients" },
|
| 49 |
+
{ value: "98%", label: "Satisfaction" },
|
| 50 |
+
].map((stat) => (
|
| 51 |
+
<div key={stat.label} className="text-center p-6 rounded-xl border bg-card">
|
| 52 |
+
<div className="text-3xl font-bold text-primary mb-1">
|
| 53 |
+
{stat.value}
|
| 54 |
+
</div>
|
| 55 |
+
<div className="text-sm text-muted-foreground">{stat.label}</div>
|
| 56 |
+
</div>
|
| 57 |
+
))}
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
{/* Values */}
|
| 61 |
+
<div className="animate-fade-in-up animate-delay-3">
|
| 62 |
+
<h2 className="text-2xl font-bold text-center mb-12">What We Stand For</h2>
|
| 63 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 64 |
+
{values.map((value, i) => (
|
| 65 |
+
<div
|
| 66 |
+
key={value.title}
|
| 67 |
+
className="animate-fade-in-up flex gap-4 p-6 rounded-xl border bg-card hover:border-primary/20 hover:shadow-lg transition-all duration-500"
|
| 68 |
+
style={{ animationDelay: `${0.4 + i * 0.1}s` }}
|
| 69 |
+
>
|
| 70 |
+
<div className="shrink-0 w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
|
| 71 |
+
<value.icon className="w-6 h-6" suppressHydrationWarning />
|
| 72 |
+
</div>
|
| 73 |
+
<div>
|
| 74 |
+
<h3 className="font-semibold mb-1">{value.title}</h3>
|
| 75 |
+
<p className="text-sm text-muted-foreground leading-relaxed">{value.description}</p>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
))}
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
)
|
| 84 |
+
}
|
app/(public)/blog/[slug]/page.tsx
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Metadata } from 'next'
|
| 2 |
+
import { notFound } from 'next/navigation'
|
| 3 |
+
import Link from 'next/link'
|
| 4 |
+
import { ArrowLeft, ArrowRight, CalendarDays, Share2, Linkedin, Twitter, Link as LinkIcon } from 'lucide-react'
|
| 5 |
+
import { format } from 'date-fns'
|
| 6 |
+
import { Button } from '@/components/ui/button'
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
| 8 |
+
|
| 9 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api'
|
| 10 |
+
|
| 11 |
+
// Define the shape of our BlogPost
|
| 12 |
+
interface BlogPost {
|
| 13 |
+
id: string
|
| 14 |
+
title: string
|
| 15 |
+
slug: string
|
| 16 |
+
content: string
|
| 17 |
+
excerpt: string | null
|
| 18 |
+
featuredImage: string | null
|
| 19 |
+
tags: string | null
|
| 20 |
+
publishedAt: string | null
|
| 21 |
+
createdAt: string
|
| 22 |
+
author: {
|
| 23 |
+
name: string | null
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// Fetch a single post by slug
|
| 28 |
+
async function getPost(slug: string): Promise<BlogPost | null> {
|
| 29 |
+
try {
|
| 30 |
+
const res = await fetch(`${API_URL}/blog/${slug}`, {
|
| 31 |
+
next: { revalidate: 60 } // Revalidate every minute
|
| 32 |
+
})
|
| 33 |
+
if (!res.ok) {
|
| 34 |
+
if (res.status === 404) return null
|
| 35 |
+
throw new Error('Failed to fetch post')
|
| 36 |
+
}
|
| 37 |
+
return res.json()
|
| 38 |
+
} catch (error) {
|
| 39 |
+
console.error("Error fetching post:", error)
|
| 40 |
+
return null
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Fetch related posts
|
| 45 |
+
async function getRelated(slug: string): Promise<BlogPost[]> {
|
| 46 |
+
try {
|
| 47 |
+
const res = await fetch(`${API_URL}/blog/${slug}/related`, {
|
| 48 |
+
next: { revalidate: 60 }
|
| 49 |
+
})
|
| 50 |
+
if (!res.ok) return []
|
| 51 |
+
return res.json()
|
| 52 |
+
} catch {
|
| 53 |
+
return []
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Dynamically generate metadata for SEO
|
| 58 |
+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
| 59 |
+
const resolvedParams = await params;
|
| 60 |
+
const post = await getPost(resolvedParams.slug)
|
| 61 |
+
|
| 62 |
+
if (!post) {
|
| 63 |
+
return {
|
| 64 |
+
title: 'Post Not Found | NexaFlow',
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const plainTextExcerpt = post.excerpt || post.content.replace(/<[^>]*>?/gm, '').substring(0, 160)
|
| 69 |
+
|
| 70 |
+
return {
|
| 71 |
+
title: `${post.title} | NexaFlow Blog`,
|
| 72 |
+
description: plainTextExcerpt,
|
| 73 |
+
openGraph: {
|
| 74 |
+
title: post.title,
|
| 75 |
+
description: plainTextExcerpt,
|
| 76 |
+
type: 'article',
|
| 77 |
+
publishedTime: post.publishedAt || post.createdAt,
|
| 78 |
+
authors: [post.author?.name || 'NexaFlow Team'],
|
| 79 |
+
images: post.featuredImage ? [{ url: post.featuredImage }] : [],
|
| 80 |
+
},
|
| 81 |
+
twitter: {
|
| 82 |
+
card: 'summary_large_image',
|
| 83 |
+
title: post.title,
|
| 84 |
+
description: plainTextExcerpt,
|
| 85 |
+
images: post.featuredImage ? [post.featuredImage] : [],
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
|
| 91 |
+
const resolvedParams = await params;
|
| 92 |
+
const post = await getPost(resolvedParams.slug)
|
| 93 |
+
|
| 94 |
+
if (!post) {
|
| 95 |
+
notFound()
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const relatedPosts = await getRelated(resolvedParams.slug)
|
| 99 |
+
|
| 100 |
+
const publishDate = post.publishedAt ? new Date(post.publishedAt) : new Date(post.createdAt)
|
| 101 |
+
const jsonLd = {
|
| 102 |
+
'@context': 'https://schema.org',
|
| 103 |
+
'@type': 'BlogPosting',
|
| 104 |
+
headline: post.title,
|
| 105 |
+
image: post.featuredImage ? [post.featuredImage] : [],
|
| 106 |
+
datePublished: publishDate.toISOString(),
|
| 107 |
+
author: {
|
| 108 |
+
'@type': 'Person',
|
| 109 |
+
name: post.author?.name || 'NexaFlow Team',
|
| 110 |
+
},
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const tags = post.tags ? JSON.parse(post.tags) : []
|
| 114 |
+
|
| 115 |
+
return (
|
| 116 |
+
<article className="py-20 animate-fade-in-up">
|
| 117 |
+
{/* Inject JSON-LD for SEO */}
|
| 118 |
+
<script
|
| 119 |
+
type="application/ld+json"
|
| 120 |
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
| 121 |
+
/>
|
| 122 |
+
|
| 123 |
+
<div className="container max-w-4xl">
|
| 124 |
+
{/* Back Link */}
|
| 125 |
+
<div className="mb-10">
|
| 126 |
+
<Button variant="ghost" asChild className="pl-0 hover:bg-transparent hover:text-primary">
|
| 127 |
+
<Link href="/blog">
|
| 128 |
+
<ArrowLeft className="mr-2 h-4 w-4" suppressHydrationWarning />
|
| 129 |
+
Back to Blog
|
| 130 |
+
</Link>
|
| 131 |
+
</Button>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
{/* Header */}
|
| 135 |
+
<header className="mb-12 text-center md:text-left">
|
| 136 |
+
{tags.length > 0 && (
|
| 137 |
+
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mb-4">
|
| 138 |
+
{tags.map((tag: string) => (
|
| 139 |
+
<span key={tag} className="text-xs font-semibold px-2.5 py-1 rounded-full bg-primary/10 text-primary">
|
| 140 |
+
{tag}
|
| 141 |
+
</span>
|
| 142 |
+
))}
|
| 143 |
+
</div>
|
| 144 |
+
)}
|
| 145 |
+
|
| 146 |
+
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-6">
|
| 147 |
+
{post.title}
|
| 148 |
+
</h1>
|
| 149 |
+
|
| 150 |
+
<div className="flex flex-col sm:flex-row items-center justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
| 151 |
+
<div className="flex items-center gap-2">
|
| 152 |
+
<span className="font-medium text-foreground">{post.author?.name || 'NexaFlow Team'}</span>
|
| 153 |
+
</div>
|
| 154 |
+
<span className="hidden sm:inline">•</span>
|
| 155 |
+
<div className="flex items-center gap-1.5">
|
| 156 |
+
<CalendarDays className="h-4 w-4" suppressHydrationWarning />
|
| 157 |
+
<time dateTime={publishDate.toISOString()}>
|
| 158 |
+
{format(publishDate, 'MMMM d, yyyy')}
|
| 159 |
+
</time>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</header>
|
| 163 |
+
|
| 164 |
+
{/* Featured Image */}
|
| 165 |
+
{post.featuredImage && (
|
| 166 |
+
<div className="relative w-full aspect-[16/9] mb-12 rounded-2xl overflow-hidden bg-muted">
|
| 167 |
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
| 168 |
+
<img
|
| 169 |
+
src={post.featuredImage}
|
| 170 |
+
alt={post.title}
|
| 171 |
+
className="object-cover w-full h-full"
|
| 172 |
+
/>
|
| 173 |
+
</div>
|
| 174 |
+
)}
|
| 175 |
+
|
| 176 |
+
{/* Content & Sidebar Layout */}
|
| 177 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
| 178 |
+
|
| 179 |
+
{/* Share Sidebar (Desktop) */}
|
| 180 |
+
<div className="hidden lg:block lg:col-span-1">
|
| 181 |
+
<div className="sticky top-24 flex flex-col gap-4">
|
| 182 |
+
<div className="h-px w-full bg-border mb-2" />
|
| 183 |
+
<p className="text-xs font-medium uppercase text-muted-foreground mb-1">Share</p>
|
| 184 |
+
<Button variant="outline" size="icon" className="rounded-full w-10 h-10 shrink-0" aria-label="Share on Twitter">
|
| 185 |
+
<Twitter className="h-4 w-4" suppressHydrationWarning />
|
| 186 |
+
</Button>
|
| 187 |
+
<Button variant="outline" size="icon" className="rounded-full w-10 h-10 shrink-0" aria-label="Share on LinkedIn">
|
| 188 |
+
<Linkedin className="h-4 w-4" suppressHydrationWarning />
|
| 189 |
+
</Button>
|
| 190 |
+
<Button variant="outline" size="icon" className="rounded-full w-10 h-10 shrink-0" aria-label="Copy link to clipboard">
|
| 191 |
+
<LinkIcon className="h-4 w-4" suppressHydrationWarning />
|
| 192 |
+
</Button>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
{/* Main Content */}
|
| 197 |
+
<div className="lg:col-span-9 prose prose-lg dark:prose-invert max-w-none prose-headings:font-bold prose-a:text-primary hover:prose-a:text-primary/80 prose-img:rounded-xl">
|
| 198 |
+
{/* We use dangerouslySetInnerHTML to render the rich text (HTML generated by the frontend editor later) */}
|
| 199 |
+
<div dangerouslySetInnerHTML={{ __html: post.content }} />
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{/* Mobile Share (Bottom) */}
|
| 204 |
+
<div className="mt-12 pt-8 border-t lg:hidden">
|
| 205 |
+
<p className="text-sm font-medium mb-4">Share this article</p>
|
| 206 |
+
<div className="flex gap-3">
|
| 207 |
+
<Button variant="outline" size="sm" aria-label="Share on Twitter">
|
| 208 |
+
<Twitter className="mr-2 h-4 w-4" suppressHydrationWarning /> Twitter
|
| 209 |
+
</Button>
|
| 210 |
+
<Button variant="outline" size="sm" aria-label="Share on LinkedIn">
|
| 211 |
+
<Linkedin className="mr-2 h-4 w-4" suppressHydrationWarning /> LinkedIn
|
| 212 |
+
</Button>
|
| 213 |
+
<Button variant="outline" size="sm" aria-label="Copy link to clipboard">
|
| 214 |
+
<Share2 className="mr-2 h-4 w-4" suppressHydrationWarning /> Copy Link
|
| 215 |
+
</Button>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
{/* Related Articles Matrix */}
|
| 220 |
+
{relatedPosts.length > 0 && (
|
| 221 |
+
<div className="mt-20 pt-16 border-t">
|
| 222 |
+
<div className="flex items-center justify-between mb-8">
|
| 223 |
+
<h2 className="text-3xl font-bold tracking-tight">Related Articles</h2>
|
| 224 |
+
<Button variant="ghost" asChild className="hidden sm:flex">
|
| 225 |
+
<Link href="/blog">
|
| 226 |
+
View all <ArrowRight className="ml-2 h-4 w-4" suppressHydrationWarning />
|
| 227 |
+
</Link>
|
| 228 |
+
</Button>
|
| 229 |
+
</div>
|
| 230 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 231 |
+
{relatedPosts.map((relatedPost) => {
|
| 232 |
+
const wordCount = relatedPost.content ? relatedPost.content.split(/\s+/).length : 0
|
| 233 |
+
const readTime = Math.max(1, Math.ceil(wordCount / 200))
|
| 234 |
+
|
| 235 |
+
const relatedTags = relatedPost.tags ? JSON.parse(relatedPost.tags) : []
|
| 236 |
+
const primaryCategory = relatedTags.length > 0 ? relatedTags[0] : "Article"
|
| 237 |
+
|
| 238 |
+
return (
|
| 239 |
+
<Link key={relatedPost.id} href={`/blog/${relatedPost.slug}`} className="w-full">
|
| 240 |
+
<Card className="h-full flex flex-col group border hover:border-primary/20 hover:shadow-lg transition-all duration-500 cursor-pointer overflow-hidden bg-background">
|
| 241 |
+
{relatedPost.featuredImage && (
|
| 242 |
+
<div className="h-40 w-full bg-muted overflow-hidden">
|
| 243 |
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
| 244 |
+
<img
|
| 245 |
+
src={relatedPost.featuredImage}
|
| 246 |
+
alt={relatedPost.title}
|
| 247 |
+
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
| 248 |
+
/>
|
| 249 |
+
</div>
|
| 250 |
+
)}
|
| 251 |
+
<CardHeader className="flex-none pb-2">
|
| 252 |
+
<div className="flex items-center justify-between mb-2">
|
| 253 |
+
<span className="text-xs font-semibold text-primary uppercase tracking-wider">{primaryCategory}</span>
|
| 254 |
+
<span className="text-xs text-muted-foreground">{readTime} min read</span>
|
| 255 |
+
</div>
|
| 256 |
+
<CardTitle className="text-lg group-hover:text-primary transition-colors line-clamp-2">{relatedPost.title}</CardTitle>
|
| 257 |
+
</CardHeader>
|
| 258 |
+
<CardContent className="flex-grow flex flex-col justify-end pt-2">
|
| 259 |
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-auto">
|
| 260 |
+
<CalendarDays className="h-3.5 w-3.5" suppressHydrationWarning />
|
| 261 |
+
{relatedPost.publishedAt ? format(new Date(relatedPost.publishedAt), 'MMM dd, yyyy') : format(new Date(relatedPost.createdAt), 'MMM dd, yyyy')}
|
| 262 |
+
</div>
|
| 263 |
+
</CardContent>
|
| 264 |
+
</Card>
|
| 265 |
+
</Link>
|
| 266 |
+
)
|
| 267 |
+
})}
|
| 268 |
+
</div>
|
| 269 |
+
<Button variant="outline" asChild className="w-full mt-6 sm:hidden">
|
| 270 |
+
<Link href="/blog">
|
| 271 |
+
View all references
|
| 272 |
+
</Link>
|
| 273 |
+
</Button>
|
| 274 |
+
</div>
|
| 275 |
+
)}
|
| 276 |
+
|
| 277 |
+
</div>
|
| 278 |
+
</article>
|
| 279 |
+
)
|
| 280 |
+
}
|
app/(public)/blog/page.tsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
| 2 |
+
import { CalendarDays } from "lucide-react"
|
| 3 |
+
import Link from "next/link"
|
| 4 |
+
import { format } from "date-fns"
|
| 5 |
+
import { Metadata } from "next"
|
| 6 |
+
|
| 7 |
+
export const metadata: Metadata = {
|
| 8 |
+
title: "Blog | NexaFlow - Insights on Web Development & Design",
|
| 9 |
+
description: "Read our latest thoughts on Next.js, web development, UI/UX design, and building great digital products.",
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000/api"
|
| 13 |
+
|
| 14 |
+
async function getPosts() {
|
| 15 |
+
try {
|
| 16 |
+
const res = await fetch(`${API_URL}/blog`, {
|
| 17 |
+
next: { revalidate: 60 } // Revalidate every minute
|
| 18 |
+
})
|
| 19 |
+
if (!res.ok) return []
|
| 20 |
+
return res.json()
|
| 21 |
+
} catch (error) {
|
| 22 |
+
console.error("Failed to fetch blog posts:", error)
|
| 23 |
+
return []
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export default async function BlogPage() {
|
| 28 |
+
const posts = await getPosts()
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="py-20">
|
| 32 |
+
<div className="container">
|
| 33 |
+
<div className="animate-fade-in-up text-center mb-16 max-w-3xl mx-auto">
|
| 34 |
+
<p className="text-sm font-semibold text-primary uppercase tracking-[0.15em] mb-3">Blog</p>
|
| 35 |
+
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl mb-6">
|
| 36 |
+
Insights &{" "}
|
| 37 |
+
<span className="text-primary">ideas</span>
|
| 38 |
+
</h1>
|
| 39 |
+
<p className="text-lg text-muted-foreground leading-relaxed">
|
| 40 |
+
Thoughts on design, development, and building great digital products.
|
| 41 |
+
</p>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
{posts.length === 0 ? (
|
| 45 |
+
<div className="text-center text-muted-foreground py-10">
|
| 46 |
+
<p>No published articles found.</p>
|
| 47 |
+
</div>
|
| 48 |
+
) : (
|
| 49 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
| 50 |
+
{posts.map((post: any, i: number) => {
|
| 51 |
+
// Quick read time estimate: 1 min per 200 words
|
| 52 |
+
const wordCount = post.content ? post.content.split(/\s+/).length : 0
|
| 53 |
+
const readTime = Math.max(1, Math.ceil(wordCount / 200))
|
| 54 |
+
|
| 55 |
+
// Safe tags
|
| 56 |
+
const tags = post.tags ? JSON.parse(post.tags) : []
|
| 57 |
+
const primaryCategory = tags.length > 0 ? tags[0] : "Article"
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
<div key={post.id} className="animate-fade-in-up flex" style={{ animationDelay: `${i * 0.1}s` }}>
|
| 61 |
+
<Link href={`/blog/${post.slug}`} className="w-full">
|
| 62 |
+
<Card className="h-full flex flex-col group border hover:border-primary/20 hover:shadow-lg transition-all duration-500 cursor-pointer overflow-hidden">
|
| 63 |
+
{post.featuredImage && (
|
| 64 |
+
<div className="h-48 w-full bg-muted overflow-hidden">
|
| 65 |
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
| 66 |
+
<img
|
| 67 |
+
src={post.featuredImage}
|
| 68 |
+
alt={post.title}
|
| 69 |
+
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
| 70 |
+
/>
|
| 71 |
+
</div>
|
| 72 |
+
)}
|
| 73 |
+
<CardHeader className="flex-none">
|
| 74 |
+
<div className="flex items-center justify-between mb-2">
|
| 75 |
+
<span className="text-xs font-semibold text-primary uppercase tracking-wider">{primaryCategory}</span>
|
| 76 |
+
<span className="text-xs text-muted-foreground">{readTime} min read</span>
|
| 77 |
+
</div>
|
| 78 |
+
<CardTitle className="text-lg group-hover:text-primary transition-colors line-clamp-2">{post.title}</CardTitle>
|
| 79 |
+
</CardHeader>
|
| 80 |
+
<CardContent className="flex-grow flex flex-col justify-between">
|
| 81 |
+
<p className="text-sm text-muted-foreground leading-relaxed mb-4 line-clamp-3">
|
| 82 |
+
{post.excerpt || post.content.replace(/<[^>]*>?/gm, '').substring(0, 150) + "..."}
|
| 83 |
+
</p>
|
| 84 |
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-auto pt-4 border-t border-border/50">
|
| 85 |
+
<CalendarDays className="h-3.5 w-3.5" suppressHydrationWarning />
|
| 86 |
+
{post.publishedAt ? format(new Date(post.publishedAt), 'MMM dd, yyyy') : format(new Date(post.createdAt), 'MMM dd, yyyy')}
|
| 87 |
+
</div>
|
| 88 |
+
</CardContent>
|
| 89 |
+
</Card>
|
| 90 |
+
</Link>
|
| 91 |
+
</div>
|
| 92 |
+
)
|
| 93 |
+
})}
|
| 94 |
+
</div>
|
| 95 |
+
)}
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
)
|
| 99 |
+
}
|
app/(public)/contact/page.tsx
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { Button } from "@/components/ui/button"
|
| 4 |
+
import { Mail, MapPin, Phone, Send } from "lucide-react"
|
| 5 |
+
|
| 6 |
+
export default function ContactPage() {
|
| 7 |
+
return (
|
| 8 |
+
<div className="py-20">
|
| 9 |
+
<div className="container">
|
| 10 |
+
<div className="animate-fade-in-up text-center mb-16 max-w-3xl mx-auto">
|
| 11 |
+
<p className="text-sm font-semibold text-primary uppercase tracking-[0.15em] mb-3">Contact</p>
|
| 12 |
+
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl mb-6">
|
| 13 |
+
Let's{" "}
|
| 14 |
+
<span className="text-primary">talk</span>
|
| 15 |
+
</h1>
|
| 16 |
+
<p className="text-lg text-muted-foreground leading-relaxed">
|
| 17 |
+
Have a project in mind? We'd love to hear about it. Drop us a message and we'll get back to you within 24 hours.
|
| 18 |
+
</p>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<div className="grid grid-cols-1 lg:grid-cols-5 gap-12 max-w-5xl mx-auto">
|
| 22 |
+
{/* Contact info */}
|
| 23 |
+
<div className="animate-fade-in-up animate-delay-2 lg:col-span-2 flex flex-col gap-6">
|
| 24 |
+
<div className="flex items-start gap-4 p-4 rounded-xl border bg-card">
|
| 25 |
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
| 26 |
+
<Mail className="w-5 h-5" suppressHydrationWarning />
|
| 27 |
+
</div>
|
| 28 |
+
<div>
|
| 29 |
+
<h3 className="font-semibold text-sm">Email</h3>
|
| 30 |
+
<p className="text-sm text-muted-foreground">hello@nexaflow.dev</p>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
<div className="flex items-start gap-4 p-4 rounded-xl border bg-card">
|
| 34 |
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
| 35 |
+
<Phone className="w-5 h-5" suppressHydrationWarning />
|
| 36 |
+
</div>
|
| 37 |
+
<div>
|
| 38 |
+
<h3 className="font-semibold text-sm">Phone</h3>
|
| 39 |
+
<p className="text-sm text-muted-foreground">+91 98765 43210</p>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
<div className="flex items-start gap-4 p-4 rounded-xl border bg-card">
|
| 43 |
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
| 44 |
+
<MapPin className="w-5 h-5" suppressHydrationWarning />
|
| 45 |
+
</div>
|
| 46 |
+
<div>
|
| 47 |
+
<h3 className="font-semibold text-sm">Location</h3>
|
| 48 |
+
<p className="text-sm text-muted-foreground">Hyderabad, India</p>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
{/* Contact form */}
|
| 54 |
+
<form
|
| 55 |
+
className="animate-fade-in-up animate-delay-3 lg:col-span-3 flex flex-col gap-5 p-8 rounded-2xl border bg-card"
|
| 56 |
+
onSubmit={(e) => e.preventDefault()}
|
| 57 |
+
>
|
| 58 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 59 |
+
<div className="flex flex-col gap-1.5">
|
| 60 |
+
<label className="text-sm font-medium" htmlFor="name">Name</label>
|
| 61 |
+
<input id="name" placeholder="John Doe" className="h-10 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50" />
|
| 62 |
+
</div>
|
| 63 |
+
<div className="flex flex-col gap-1.5">
|
| 64 |
+
<label className="text-sm font-medium" htmlFor="email">Email</label>
|
| 65 |
+
<input id="email" type="email" placeholder="john@example.com" className="h-10 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50" />
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
<div className="flex flex-col gap-1.5">
|
| 69 |
+
<label className="text-sm font-medium" htmlFor="subject">Subject</label>
|
| 70 |
+
<input id="subject" placeholder="Project inquiry" className="h-10 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50" />
|
| 71 |
+
</div>
|
| 72 |
+
<div className="flex flex-col gap-1.5">
|
| 73 |
+
<label className="text-sm font-medium" htmlFor="message">Message</label>
|
| 74 |
+
<textarea id="message" rows={5} placeholder="Tell us about your project..." className="rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none" />
|
| 75 |
+
</div>
|
| 76 |
+
<Button type="submit" size="lg" className="self-start group">
|
| 77 |
+
Send Message
|
| 78 |
+
<Send className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" suppressHydrationWarning />
|
| 79 |
+
</Button>
|
| 80 |
+
</form>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
)
|
| 85 |
+
}
|
app/(public)/layout.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Header } from "@/components/layout/Header"
|
| 2 |
+
import { Footer } from "@/components/layout/Footer"
|
| 3 |
+
|
| 4 |
+
export default function PublicLayout({
|
| 5 |
+
children,
|
| 6 |
+
}: {
|
| 7 |
+
children: React.ReactNode
|
| 8 |
+
}) {
|
| 9 |
+
return (
|
| 10 |
+
<div className="flex min-h-screen flex-col">
|
| 11 |
+
<Header />
|
| 12 |
+
<main className="flex-1">{children}</main>
|
| 13 |
+
<Footer />
|
| 14 |
+
</div>
|
| 15 |
+
)
|
| 16 |
+
}
|
app/(public)/page.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { HeroSection } from "@/components/home/HeroSection";
|
| 2 |
+
import { TrustIndicators } from "@/components/home/TrustIndicators";
|
| 3 |
+
import { ServicesOverview } from "@/components/home/ServicesOverview";
|
| 4 |
+
import { FeaturedPortfolio } from "@/components/home/FeaturedPortfolio";
|
| 5 |
+
import { TestimonialsCarousel } from "@/components/home/TestimonialsCarousel";
|
| 6 |
+
import { CTASection } from "@/components/home/CTASection";
|
| 7 |
+
|
| 8 |
+
export default function Home() {
|
| 9 |
+
return (
|
| 10 |
+
<>
|
| 11 |
+
<HeroSection />
|
| 12 |
+
<TrustIndicators />
|
| 13 |
+
<ServicesOverview />
|
| 14 |
+
<FeaturedPortfolio />
|
| 15 |
+
<TestimonialsCarousel />
|
| 16 |
+
<CTASection />
|
| 17 |
+
</>
|
| 18 |
+
);
|
| 19 |
+
}
|
app/(public)/portfolio/[slug]/page.tsx
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { notFound } from "next/navigation"
|
| 2 |
+
import Link from "next/link"
|
| 3 |
+
import { Button } from "@/components/ui/button"
|
| 4 |
+
import { Card, CardContent, CardFooter } from "@/components/ui/card"
|
| 5 |
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
| 6 |
+
import { ArrowLeft, Star, ExternalLink } from "lucide-react"
|
| 7 |
+
import { projects } from "@/lib/portfolio-data"
|
| 8 |
+
|
| 9 |
+
export function generateStaticParams() {
|
| 10 |
+
return projects.map((p) => ({ slug: p.slug }))
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
| 14 |
+
const { slug } = await params
|
| 15 |
+
const project = projects.find((p) => p.slug === slug)
|
| 16 |
+
if (!project) return {}
|
| 17 |
+
return {
|
| 18 |
+
title: `${project.title} — NexaFlow Portfolio`,
|
| 19 |
+
description: project.description,
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export default async function CaseStudyPage({ params }: { params: Promise<{ slug: string }> }) {
|
| 24 |
+
const { slug } = await params
|
| 25 |
+
const project = projects.find((p) => p.slug === slug)
|
| 26 |
+
if (!project) notFound()
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<article className="pb-24">
|
| 30 |
+
{/* Hero banner */}
|
| 31 |
+
<div className={`relative bg-gradient-to-br ${project.gradient} py-20 md:py-28`}>
|
| 32 |
+
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(255,255,255,0.05)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.05)_1px,transparent_1px)] bg-[size:3rem_3rem]" />
|
| 33 |
+
<div className="container relative z-10">
|
| 34 |
+
<Button asChild variant="outline" size="sm" className="mb-8 bg-white/10 border-white/20 text-white hover:bg-white/20">
|
| 35 |
+
<Link href="/portfolio">
|
| 36 |
+
<ArrowLeft className="mr-2 h-4 w-4" suppressHydrationWarning />
|
| 37 |
+
Back to Portfolio
|
| 38 |
+
</Link>
|
| 39 |
+
</Button>
|
| 40 |
+
<p className="text-white/70 text-sm font-semibold uppercase tracking-widest mb-3">{project.category}</p>
|
| 41 |
+
<h1 className="text-white sm:text-5xl lg:text-6xl mb-4">{project.title}</h1>
|
| 42 |
+
<p className="text-white/80 text-lg max-w-2xl leading-relaxed">{project.description}</p>
|
| 43 |
+
|
| 44 |
+
{/* Technology tags (SF-015) */}
|
| 45 |
+
<div className="flex flex-wrap gap-2 mt-6">
|
| 46 |
+
{project.tags.map((tag) => (
|
| 47 |
+
<span key={tag} className="bg-white/15 text-white px-3 py-1 rounded-full text-xs font-medium backdrop-blur-sm">
|
| 48 |
+
{tag}
|
| 49 |
+
</span>
|
| 50 |
+
))}
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div className="container mt-16">
|
| 56 |
+
{/* Key results metrics */}
|
| 57 |
+
{project.results && project.results.length > 0 && (
|
| 58 |
+
<div className="animate-fade-in-up grid grid-cols-2 md:grid-cols-4 gap-6 mb-16 -mt-8 relative z-20">
|
| 59 |
+
{project.results.map((r) => (
|
| 60 |
+
<div key={r.metric} className="rounded-2xl border bg-card p-6 text-center shadow-lg">
|
| 61 |
+
<div className="text-2xl font-extrabold text-primary mb-1">{r.value}</div>
|
| 62 |
+
<p className="text-caption">{r.metric}</p>
|
| 63 |
+
</div>
|
| 64 |
+
))}
|
| 65 |
+
</div>
|
| 66 |
+
)}
|
| 67 |
+
|
| 68 |
+
{/* Case study content */}
|
| 69 |
+
<div className="max-w-3xl mx-auto space-y-12">
|
| 70 |
+
{/* Overview */}
|
| 71 |
+
<section className="animate-fade-in-up">
|
| 72 |
+
<h2 className="mb-4">Overview</h2>
|
| 73 |
+
<p className="text-body text-muted-foreground leading-relaxed">{project.overview}</p>
|
| 74 |
+
</section>
|
| 75 |
+
|
| 76 |
+
{/* Challenge */}
|
| 77 |
+
<section className="animate-fade-in-up animate-delay-1">
|
| 78 |
+
<h2 className="mb-4">The Challenge</h2>
|
| 79 |
+
<div className="rounded-2xl border-l-4 border-destructive bg-destructive/5 p-6">
|
| 80 |
+
<p className="text-body text-muted-foreground leading-relaxed">{project.challenge}</p>
|
| 81 |
+
</div>
|
| 82 |
+
</section>
|
| 83 |
+
|
| 84 |
+
{/* Solution */}
|
| 85 |
+
<section className="animate-fade-in-up animate-delay-2">
|
| 86 |
+
<h2 className="mb-4">Our Solution</h2>
|
| 87 |
+
<div className="rounded-2xl border-l-4 border-primary bg-primary/5 p-6">
|
| 88 |
+
<p className="text-body text-muted-foreground leading-relaxed">{project.solution}</p>
|
| 89 |
+
</div>
|
| 90 |
+
</section>
|
| 91 |
+
|
| 92 |
+
{/* Technologies Used */}
|
| 93 |
+
<section className="animate-fade-in-up animate-delay-3">
|
| 94 |
+
<h2 className="mb-4">Technologies Used</h2>
|
| 95 |
+
<div className="flex flex-wrap gap-3">
|
| 96 |
+
{project.tags.map((tag) => (
|
| 97 |
+
<span key={tag} className="bg-secondary text-secondary-foreground px-4 py-2 rounded-xl text-sm font-medium">
|
| 98 |
+
{tag}
|
| 99 |
+
</span>
|
| 100 |
+
))}
|
| 101 |
+
</div>
|
| 102 |
+
</section>
|
| 103 |
+
|
| 104 |
+
{/* Client Testimonial (SF-017) */}
|
| 105 |
+
{project.testimonial && (
|
| 106 |
+
<section className="animate-fade-in-up">
|
| 107 |
+
<h2 className="mb-4">Client Feedback</h2>
|
| 108 |
+
<Card className="bg-muted border">
|
| 109 |
+
<CardContent className="pt-6">
|
| 110 |
+
<div className="flex gap-0.5 mb-4" role="img" aria-label={`${project.testimonial.rating} out of 5 stars`}>
|
| 111 |
+
{[...Array(project.testimonial.rating)].map((_, j) => (
|
| 112 |
+
<Star key={j} className="w-4 h-4 fill-amber-400 text-amber-400" suppressHydrationWarning />
|
| 113 |
+
))}
|
| 114 |
+
</div>
|
| 115 |
+
<blockquote className="text-body text-foreground leading-relaxed">
|
| 116 |
+
“{project.testimonial.content}”
|
| 117 |
+
</blockquote>
|
| 118 |
+
</CardContent>
|
| 119 |
+
<CardFooter className="flex items-center gap-4 border-t pt-4">
|
| 120 |
+
<Avatar className="h-10 w-10">
|
| 121 |
+
<AvatarFallback className="bg-primary/10 text-primary text-sm font-semibold">
|
| 122 |
+
{project.testimonial.avatar}
|
| 123 |
+
</AvatarFallback>
|
| 124 |
+
</Avatar>
|
| 125 |
+
<div>
|
| 126 |
+
<p className="font-semibold text-sm">{project.testimonial.name}</p>
|
| 127 |
+
<p className="text-caption">{project.testimonial.role}, {project.testimonial.company}</p>
|
| 128 |
+
</div>
|
| 129 |
+
</CardFooter>
|
| 130 |
+
</Card>
|
| 131 |
+
</section>
|
| 132 |
+
)}
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
{/* CTA */}
|
| 136 |
+
<div className="animate-fade-in-up text-center mt-16 pt-12 border-t">
|
| 137 |
+
<h2 className="mb-4">Ready to build something like this?</h2>
|
| 138 |
+
<p className="text-body text-muted-foreground mb-8 max-w-lg mx-auto">
|
| 139 |
+
Let's discuss your project and see how we can help you achieve similar results.
|
| 140 |
+
</p>
|
| 141 |
+
<div className="flex justify-center gap-4">
|
| 142 |
+
<Button asChild size="xl">
|
| 143 |
+
<Link href="/quote">Start Your Project</Link>
|
| 144 |
+
</Button>
|
| 145 |
+
<Button asChild variant="outline" size="lg">
|
| 146 |
+
<Link href="/portfolio">
|
| 147 |
+
<ArrowLeft className="mr-2 h-4 w-4" suppressHydrationWarning />
|
| 148 |
+
Back to Portfolio
|
| 149 |
+
</Link>
|
| 150 |
+
</Button>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
</article>
|
| 155 |
+
)
|
| 156 |
+
}
|
app/(public)/portfolio/page.tsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ProjectGallery, Project } from "@/components/portfolio/ProjectGallery"
|
| 2 |
+
|
| 3 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000/api"
|
| 4 |
+
|
| 5 |
+
async function getPortfolioItems() {
|
| 6 |
+
try {
|
| 7 |
+
const res = await fetch(`${API_URL}/portfolio`, { cache: 'no-store' })
|
| 8 |
+
if (!res.ok) return []
|
| 9 |
+
const data = await res.json()
|
| 10 |
+
|
| 11 |
+
return data.map((item: any) => ({
|
| 12 |
+
title: item.title,
|
| 13 |
+
slug: item.slug,
|
| 14 |
+
category: item.industry || "General",
|
| 15 |
+
description: item.description,
|
| 16 |
+
tags: item.technologies ? item.technologies.split(',').map((t: string) => t.trim()) : [],
|
| 17 |
+
imageUrl: item.imageUrl,
|
| 18 |
+
gradient: "from-gray-800 to-gray-600",
|
| 19 |
+
emoji: "✨",
|
| 20 |
+
}))
|
| 21 |
+
} catch (e) {
|
| 22 |
+
console.error("Failed to fetch public portfolio items", e)
|
| 23 |
+
return []
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export default async function PortfolioPage() {
|
| 28 |
+
const projects: Project[] = await getPortfolioItems()
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="py-20">
|
| 32 |
+
<div className="container">
|
| 33 |
+
{/* Hero header */}
|
| 34 |
+
<div className="animate-fade-in-up text-center mb-16 max-w-3xl mx-auto">
|
| 35 |
+
<p className="text-overline text-primary mb-3">Our Work</p>
|
| 36 |
+
<h1 className="sm:text-5xl mb-6">
|
| 37 |
+
Projects that{" "}
|
| 38 |
+
<span className="text-primary">speak for themselves</span>
|
| 39 |
+
</h1>
|
| 40 |
+
<p className="text-body text-muted-foreground">
|
| 41 |
+
A curated selection of our best work across web development, mobile apps, and design.
|
| 42 |
+
</p>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
{/* Gallery with filters (SF-013) */}
|
| 46 |
+
<ProjectGallery projects={projects} />
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
)
|
| 50 |
+
}
|
app/(public)/quote/page.tsx
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useCallback } from "react"
|
| 4 |
+
import Link from "next/link"
|
| 5 |
+
import { Button } from "@/components/ui/button"
|
| 6 |
+
import { Input } from "@/components/ui/input"
|
| 7 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 8 |
+
import {
|
| 9 |
+
ArrowRight,
|
| 10 |
+
ArrowLeft,
|
| 11 |
+
CheckCircle2,
|
| 12 |
+
Globe,
|
| 13 |
+
Smartphone,
|
| 14 |
+
Palette,
|
| 15 |
+
Search,
|
| 16 |
+
HelpCircle,
|
| 17 |
+
DollarSign,
|
| 18 |
+
Clock,
|
| 19 |
+
Zap,
|
| 20 |
+
CalendarDays,
|
| 21 |
+
Timer,
|
| 22 |
+
Infinity,
|
| 23 |
+
Upload,
|
| 24 |
+
Loader2,
|
| 25 |
+
PartyPopper,
|
| 26 |
+
FileText,
|
| 27 |
+
X,
|
| 28 |
+
} from "lucide-react"
|
| 29 |
+
|
| 30 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000/api"
|
| 31 |
+
|
| 32 |
+
/* ─── Step data ─── */
|
| 33 |
+
|
| 34 |
+
const projectTypes = [
|
| 35 |
+
{ value: "website", label: "Website", desc: "Marketing sites, landing pages, corporate websites", icon: Globe },
|
| 36 |
+
{ value: "webapp", label: "Web Application", desc: "SaaS platforms, dashboards, custom tools", icon: Zap },
|
| 37 |
+
{ value: "mobile", label: "Mobile App", desc: "iOS, Android, or cross-platform apps", icon: Smartphone },
|
| 38 |
+
{ value: "design", label: "UI/UX Design", desc: "User research, wireframes, prototypes", icon: Palette },
|
| 39 |
+
{ value: "seo", label: "SEO Optimization", desc: "Audit, strategy, and ongoing optimization", icon: Search },
|
| 40 |
+
{ value: "other", label: "Other", desc: "Custom requirements or multiple services", icon: HelpCircle },
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
const budgetRanges = [
|
| 44 |
+
{ value: "$1K–$5K", label: "$1K – $5K", desc: "Small projects, MVPs, single-page sites", icon: DollarSign },
|
| 45 |
+
{ value: "$5K–$15K", label: "$5K – $15K", desc: "Multi-page websites, mobile apps", icon: DollarSign },
|
| 46 |
+
{ value: "$15K–$50K", label: "$15K – $50K", desc: "Complex platforms, enterprise solutions", icon: DollarSign },
|
| 47 |
+
{ value: "$50K+", label: "$50K+", desc: "Large-scale projects, ongoing partnerships", icon: DollarSign },
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
const timelineOptions = [
|
| 51 |
+
{ value: "ASAP", label: "ASAP", desc: "As soon as possible", icon: Zap },
|
| 52 |
+
{ value: "1-2 months", label: "1–2 Months", desc: "Standard delivery timeline", icon: Clock },
|
| 53 |
+
{ value: "3-6 months", label: "3–6 Months", desc: "Complex or phased rollout", icon: CalendarDays },
|
| 54 |
+
{ value: "Flexible", label: "Flexible", desc: "No strict deadline", icon: Infinity },
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
const estimateMap: Record<string, Record<string, string>> = {
|
| 58 |
+
"$1K–$5K": { website: "$2K–$4K", webapp: "$3K–$5K", mobile: "$4K–$5K", design: "$1K–$3K", seo: "$1K–$3K", other: "$2K–$5K" },
|
| 59 |
+
"$5K–$15K": { website: "$6K–$12K", webapp: "$8K–$15K", mobile: "$8K–$14K", design: "$5K–$10K", seo: "$5K–$8K", other: "$6K–$12K" },
|
| 60 |
+
"$15K–$50K": { website: "$15K–$30K", webapp: "$20K–$45K", mobile: "$18K–$40K", design: "$12K–$25K", seo: "$10K–$20K", other: "$15K–$35K" },
|
| 61 |
+
"$50K+": { website: "$50K–$80K", webapp: "$50K–$120K", mobile: "$50K–$100K", design: "$30K–$60K", seo: "$25K–$50K", other: "$50K+" },
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
type FormState = {
|
| 65 |
+
projectType: string
|
| 66 |
+
description: string
|
| 67 |
+
budget: string
|
| 68 |
+
timeline: string
|
| 69 |
+
name: string
|
| 70 |
+
email: string
|
| 71 |
+
phone: string
|
| 72 |
+
company: string
|
| 73 |
+
files: File[]
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const initialState: FormState = {
|
| 77 |
+
projectType: "",
|
| 78 |
+
description: "",
|
| 79 |
+
budget: "",
|
| 80 |
+
timeline: "",
|
| 81 |
+
name: "",
|
| 82 |
+
email: "",
|
| 83 |
+
phone: "",
|
| 84 |
+
company: "",
|
| 85 |
+
files: [],
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const TOTAL_STEPS = 4
|
| 89 |
+
|
| 90 |
+
export default function QuotePage() {
|
| 91 |
+
const [step, setStep] = useState(1)
|
| 92 |
+
const [form, setForm] = useState<FormState>(initialState)
|
| 93 |
+
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
| 94 |
+
const [submitting, setSubmitting] = useState(false)
|
| 95 |
+
const [submitted, setSubmitted] = useState(false)
|
| 96 |
+
const [submitError, setSubmitError] = useState("")
|
| 97 |
+
const [inquiryId, setInquiryId] = useState("")
|
| 98 |
+
|
| 99 |
+
const set = useCallback(<K extends keyof FormState>(key: K, value: FormState[K]) => {
|
| 100 |
+
setForm((prev) => ({ ...prev, [key]: value }))
|
| 101 |
+
setErrors((prev) => ({ ...prev, [key]: undefined }))
|
| 102 |
+
}, [])
|
| 103 |
+
|
| 104 |
+
/* ─── Validation ─── */
|
| 105 |
+
const validateStep = (): boolean => {
|
| 106 |
+
const errs: typeof errors = {}
|
| 107 |
+
if (step === 1) {
|
| 108 |
+
if (!form.projectType) errs.projectType = "Please select a project type"
|
| 109 |
+
if (form.description.trim().length < 10) errs.description = "Please describe your project (min 10 characters)"
|
| 110 |
+
}
|
| 111 |
+
if (step === 2) {
|
| 112 |
+
if (!form.budget) errs.budget = "Please select a budget range"
|
| 113 |
+
}
|
| 114 |
+
if (step === 3) {
|
| 115 |
+
if (!form.timeline) errs.timeline = "Please select a timeline"
|
| 116 |
+
}
|
| 117 |
+
if (step === 4) {
|
| 118 |
+
if (form.name.trim().length < 2) errs.name = "Name is required"
|
| 119 |
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errs.email = "A valid email is required"
|
| 120 |
+
}
|
| 121 |
+
setErrors(errs)
|
| 122 |
+
return Object.keys(errs).length === 0
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const next = () => { if (validateStep()) setStep((s) => Math.min(s + 1, TOTAL_STEPS)) }
|
| 126 |
+
const back = () => setStep((s) => Math.max(s - 1, 1))
|
| 127 |
+
|
| 128 |
+
/* ─── File handling ─── */
|
| 129 |
+
const ACCEPTED = [".pdf", ".doc", ".docx", ".png", ".jpg", ".jpeg"]
|
| 130 |
+
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
|
| 131 |
+
const MAX_FILES = 3
|
| 132 |
+
|
| 133 |
+
const addFiles = (fileList: FileList | null) => {
|
| 134 |
+
if (!fileList) return
|
| 135 |
+
const newFiles = Array.from(fileList).filter((f) => {
|
| 136 |
+
const ext = "." + f.name.split(".").pop()?.toLowerCase()
|
| 137 |
+
if (!ACCEPTED.includes(ext)) return false
|
| 138 |
+
if (f.size > MAX_SIZE) return false
|
| 139 |
+
return true
|
| 140 |
+
})
|
| 141 |
+
set("files", [...form.files, ...newFiles].slice(0, MAX_FILES))
|
| 142 |
+
}
|
| 143 |
+
const removeFile = (idx: number) => set("files", form.files.filter((_, i) => i !== idx))
|
| 144 |
+
|
| 145 |
+
/* ─── Submit ─── */
|
| 146 |
+
const handleSubmit = async () => {
|
| 147 |
+
if (!validateStep()) return
|
| 148 |
+
setSubmitting(true)
|
| 149 |
+
setSubmitError("")
|
| 150 |
+
try {
|
| 151 |
+
// First fetch the CSRF token
|
| 152 |
+
const csrfRes = await fetch(`${API_URL}/csrf-token`, { method: "GET" })
|
| 153 |
+
if (!csrfRes.ok) throw new Error("Could not initialize secure session")
|
| 154 |
+
const { csrfToken } = await csrfRes.json()
|
| 155 |
+
|
| 156 |
+
const res = await fetch(`${API_URL}/inquiries`, {
|
| 157 |
+
method: "POST",
|
| 158 |
+
headers: {
|
| 159 |
+
"Content-Type": "application/json",
|
| 160 |
+
"X-CSRF-Token": csrfToken
|
| 161 |
+
},
|
| 162 |
+
body: JSON.stringify({
|
| 163 |
+
name: form.name.trim(),
|
| 164 |
+
email: form.email.trim().toLowerCase(),
|
| 165 |
+
phone: form.phone.trim() || null,
|
| 166 |
+
company: form.company.trim() || null,
|
| 167 |
+
projectType: form.projectType,
|
| 168 |
+
budget: form.budget,
|
| 169 |
+
timeline: form.timeline,
|
| 170 |
+
description: form.description.trim(),
|
| 171 |
+
}),
|
| 172 |
+
})
|
| 173 |
+
const data = await res.json()
|
| 174 |
+
if (!res.ok) {
|
| 175 |
+
setSubmitError(data.error || "Something went wrong")
|
| 176 |
+
return
|
| 177 |
+
}
|
| 178 |
+
setInquiryId(data.id)
|
| 179 |
+
setSubmitted(true)
|
| 180 |
+
} catch (err: any) {
|
| 181 |
+
setSubmitError(err.message || "Network error. Please try again.")
|
| 182 |
+
} finally {
|
| 183 |
+
setSubmitting(false)
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/* ─── Estimate ─── */
|
| 188 |
+
const estimate = form.budget && form.projectType
|
| 189 |
+
? estimateMap[form.budget]?.[form.projectType] || null
|
| 190 |
+
: null
|
| 191 |
+
|
| 192 |
+
/* ─── Success screen ─── */
|
| 193 |
+
if (submitted) {
|
| 194 |
+
return (
|
| 195 |
+
<div className="py-20">
|
| 196 |
+
<div className="container max-w-2xl mx-auto text-center animate-fade-in-up">
|
| 197 |
+
<div className="w-20 h-20 mx-auto rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-6">
|
| 198 |
+
<PartyPopper className="h-10 w-10 text-green-600 dark:text-green-400" suppressHydrationWarning />
|
| 199 |
+
</div>
|
| 200 |
+
<h1 className="text-3xl font-bold mb-3">Quote Request Submitted!</h1>
|
| 201 |
+
<p className="text-muted-foreground mb-2">
|
| 202 |
+
Your reference number is <span className="font-mono font-semibold text-foreground">{inquiryId}</span>
|
| 203 |
+
</p>
|
| 204 |
+
<p className="text-muted-foreground mb-8">
|
| 205 |
+
We'll review your request and get back to you within 24 hours.
|
| 206 |
+
</p>
|
| 207 |
+
|
| 208 |
+
{estimate && (
|
| 209 |
+
<Card className="mb-8 border-primary/20 bg-primary/5">
|
| 210 |
+
<CardContent className="py-6">
|
| 211 |
+
<p className="text-sm text-muted-foreground mb-1">Estimated project range</p>
|
| 212 |
+
<p className="text-3xl font-extrabold text-primary">{estimate}</p>
|
| 213 |
+
<p className="text-xs text-muted-foreground mt-2">
|
| 214 |
+
This is a rough estimate — your actual quote may vary based on project details.
|
| 215 |
+
</p>
|
| 216 |
+
</CardContent>
|
| 217 |
+
</Card>
|
| 218 |
+
)}
|
| 219 |
+
|
| 220 |
+
<div className="flex justify-center gap-4">
|
| 221 |
+
<Button asChild size="lg">
|
| 222 |
+
<Link href="/contact">Book a Consultation</Link>
|
| 223 |
+
</Button>
|
| 224 |
+
<Button asChild variant="outline" size="lg">
|
| 225 |
+
<Link href="/">Back to Home</Link>
|
| 226 |
+
</Button>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
)
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
/* ─── Form steps ─── */
|
| 234 |
+
return (
|
| 235 |
+
<div className="py-20">
|
| 236 |
+
<div className="container max-w-3xl mx-auto">
|
| 237 |
+
{/* Header */}
|
| 238 |
+
<div className="text-center mb-10 animate-fade-in-up">
|
| 239 |
+
<p className="text-sm font-semibold text-primary uppercase tracking-[0.15em] mb-3">Get a Quote</p>
|
| 240 |
+
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl mb-4">
|
| 241 |
+
Let's bring your <span className="text-primary">idea to life</span>
|
| 242 |
+
</h1>
|
| 243 |
+
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
|
| 244 |
+
Answer a few questions and we'll send you a detailed proposal.
|
| 245 |
+
</p>
|
| 246 |
+
</div>
|
| 247 |
+
|
| 248 |
+
{/* Progress indicator */}
|
| 249 |
+
<div className="flex items-center justify-center gap-2 mb-10" role="progressbar" aria-valuenow={step} aria-valuemin={1} aria-valuemax={TOTAL_STEPS}>
|
| 250 |
+
{[1, 2, 3, 4].map((s) => (
|
| 251 |
+
<div key={s} className="flex items-center gap-2">
|
| 252 |
+
<div
|
| 253 |
+
className={`w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold transition-all duration-300 ${s < step
|
| 254 |
+
? "bg-primary text-primary-foreground"
|
| 255 |
+
: s === step
|
| 256 |
+
? "bg-primary text-primary-foreground ring-4 ring-primary/20"
|
| 257 |
+
: "bg-muted text-muted-foreground"
|
| 258 |
+
}`}
|
| 259 |
+
>
|
| 260 |
+
{s < step ? <CheckCircle2 className="h-5 w-5" suppressHydrationWarning /> : s}
|
| 261 |
+
</div>
|
| 262 |
+
{s < 4 && (
|
| 263 |
+
<div className={`w-12 h-0.5 transition-colors duration-300 ${s < step ? "bg-primary" : "bg-muted"}`} />
|
| 264 |
+
)}
|
| 265 |
+
</div>
|
| 266 |
+
))}
|
| 267 |
+
</div>
|
| 268 |
+
|
| 269 |
+
{/* Step card */}
|
| 270 |
+
<Card className="animate-fade-in-up border shadow-lg">
|
| 271 |
+
<CardContent className="p-8">
|
| 272 |
+
{/* ─── STEP 1: Project Details ─── */}
|
| 273 |
+
{step === 1 && (
|
| 274 |
+
<fieldset className="space-y-6">
|
| 275 |
+
<legend className="text-xl font-bold mb-1">What are you looking for?</legend>
|
| 276 |
+
<p className="text-sm text-muted-foreground">Select the type of project and describe your vision.</p>
|
| 277 |
+
|
| 278 |
+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
| 279 |
+
{projectTypes.map((pt) => (
|
| 280 |
+
<button
|
| 281 |
+
key={pt.value}
|
| 282 |
+
type="button"
|
| 283 |
+
onClick={() => set("projectType", pt.value)}
|
| 284 |
+
className={`flex flex-col items-center gap-2 p-4 rounded-xl border text-center transition-all duration-200 hover:border-primary/40 hover:shadow-md ${form.projectType === pt.value
|
| 285 |
+
? "border-primary bg-primary/5 ring-2 ring-primary/20 shadow-md"
|
| 286 |
+
: "border-border"
|
| 287 |
+
}`}
|
| 288 |
+
>
|
| 289 |
+
<pt.icon className={`h-6 w-6 ${form.projectType === pt.value ? "text-primary" : "text-muted-foreground"}`} suppressHydrationWarning />
|
| 290 |
+
<span className="font-medium text-sm">{pt.label}</span>
|
| 291 |
+
<span className="text-xs text-muted-foreground leading-tight hidden sm:block">{pt.desc}</span>
|
| 292 |
+
</button>
|
| 293 |
+
))}
|
| 294 |
+
</div>
|
| 295 |
+
{errors.projectType && <p className="text-sm text-destructive">{errors.projectType}</p>}
|
| 296 |
+
|
| 297 |
+
<div>
|
| 298 |
+
<label className="text-sm font-medium mb-1.5 block" htmlFor="description">
|
| 299 |
+
Tell us about your project *
|
| 300 |
+
</label>
|
| 301 |
+
<textarea
|
| 302 |
+
id="description"
|
| 303 |
+
className="flex min-h-[100px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none"
|
| 304 |
+
value={form.description}
|
| 305 |
+
onChange={(e) => set("description", e.target.value)}
|
| 306 |
+
placeholder="Describe your goals, target audience, key features, and any inspiration..."
|
| 307 |
+
maxLength={2000}
|
| 308 |
+
/>
|
| 309 |
+
<div className="flex justify-between mt-1">
|
| 310 |
+
{errors.description ? (
|
| 311 |
+
<p className="text-sm text-destructive">{errors.description}</p>
|
| 312 |
+
) : <span />}
|
| 313 |
+
<span className="text-xs text-muted-foreground">{form.description.length}/2000</span>
|
| 314 |
+
</div>
|
| 315 |
+
</div>
|
| 316 |
+
</fieldset>
|
| 317 |
+
)}
|
| 318 |
+
|
| 319 |
+
{/* ─── STEP 2: Budget ─── */}
|
| 320 |
+
{step === 2 && (
|
| 321 |
+
<fieldset className="space-y-6">
|
| 322 |
+
<legend className="text-xl font-bold mb-1">What's your budget?</legend>
|
| 323 |
+
<p className="text-sm text-muted-foreground">Select the range that best fits your project investment.</p>
|
| 324 |
+
|
| 325 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 326 |
+
{budgetRanges.map((b, i) => (
|
| 327 |
+
<button
|
| 328 |
+
key={b.value}
|
| 329 |
+
type="button"
|
| 330 |
+
onClick={() => set("budget", b.value)}
|
| 331 |
+
className={`flex items-center gap-4 p-5 rounded-xl border text-left transition-all duration-200 hover:border-primary/40 hover:shadow-md ${form.budget === b.value
|
| 332 |
+
? "border-primary bg-primary/5 ring-2 ring-primary/20 shadow-md"
|
| 333 |
+
: "border-border"
|
| 334 |
+
}`}
|
| 335 |
+
>
|
| 336 |
+
<div className={`flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center text-sm font-bold ${form.budget === b.value
|
| 337 |
+
? "bg-primary text-primary-foreground"
|
| 338 |
+
: "bg-muted text-muted-foreground"
|
| 339 |
+
}`}>
|
| 340 |
+
{"$".repeat(i + 1)}
|
| 341 |
+
</div>
|
| 342 |
+
<div>
|
| 343 |
+
<p className="font-semibold">{b.label}</p>
|
| 344 |
+
<p className="text-xs text-muted-foreground">{b.desc}</p>
|
| 345 |
+
</div>
|
| 346 |
+
</button>
|
| 347 |
+
))}
|
| 348 |
+
</div>
|
| 349 |
+
{errors.budget && <p className="text-sm text-destructive">{errors.budget}</p>}
|
| 350 |
+
</fieldset>
|
| 351 |
+
)}
|
| 352 |
+
|
| 353 |
+
{/* ─── STEP 3: Timeline ─── */}
|
| 354 |
+
{step === 3 && (
|
| 355 |
+
<fieldset className="space-y-6">
|
| 356 |
+
<legend className="text-xl font-bold mb-1">When do you need it?</legend>
|
| 357 |
+
<p className="text-sm text-muted-foreground">Select your preferred timeline.</p>
|
| 358 |
+
|
| 359 |
+
<div className="grid grid-cols-2 gap-4">
|
| 360 |
+
{timelineOptions.map((t) => (
|
| 361 |
+
<button
|
| 362 |
+
key={t.value}
|
| 363 |
+
type="button"
|
| 364 |
+
onClick={() => set("timeline", t.value)}
|
| 365 |
+
className={`flex items-center gap-4 p-5 rounded-xl border text-left transition-all duration-200 hover:border-primary/40 hover:shadow-md ${form.timeline === t.value
|
| 366 |
+
? "border-primary bg-primary/5 ring-2 ring-primary/20 shadow-md"
|
| 367 |
+
: "border-border"
|
| 368 |
+
}`}
|
| 369 |
+
>
|
| 370 |
+
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${form.timeline === t.value
|
| 371 |
+
? "bg-primary text-primary-foreground"
|
| 372 |
+
: "bg-muted text-muted-foreground"
|
| 373 |
+
}`}>
|
| 374 |
+
<t.icon className="h-5 w-5" suppressHydrationWarning />
|
| 375 |
+
</div>
|
| 376 |
+
<div>
|
| 377 |
+
<p className="font-semibold">{t.label}</p>
|
| 378 |
+
<p className="text-xs text-muted-foreground">{t.desc}</p>
|
| 379 |
+
</div>
|
| 380 |
+
</button>
|
| 381 |
+
))}
|
| 382 |
+
</div>
|
| 383 |
+
{errors.timeline && <p className="text-sm text-destructive">{errors.timeline}</p>}
|
| 384 |
+
</fieldset>
|
| 385 |
+
)}
|
| 386 |
+
|
| 387 |
+
{/* ─── STEP 4: Contact & Upload ─── */}
|
| 388 |
+
{step === 4 && (
|
| 389 |
+
<fieldset className="space-y-5">
|
| 390 |
+
<legend className="text-xl font-bold mb-1">Almost there!</legend>
|
| 391 |
+
<p className="text-sm text-muted-foreground">How can we reach you?</p>
|
| 392 |
+
|
| 393 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 394 |
+
<div>
|
| 395 |
+
<label className="text-sm font-medium mb-1.5 block" htmlFor="name">Full Name *</label>
|
| 396 |
+
<Input
|
| 397 |
+
id="name"
|
| 398 |
+
value={form.name}
|
| 399 |
+
onChange={(e) => set("name", e.target.value)}
|
| 400 |
+
placeholder="John Doe"
|
| 401 |
+
/>
|
| 402 |
+
{errors.name && <p className="text-sm text-destructive mt-1">{errors.name}</p>}
|
| 403 |
+
</div>
|
| 404 |
+
<div>
|
| 405 |
+
<label className="text-sm font-medium mb-1.5 block" htmlFor="email">Email *</label>
|
| 406 |
+
<Input
|
| 407 |
+
id="email"
|
| 408 |
+
type="email"
|
| 409 |
+
value={form.email}
|
| 410 |
+
onChange={(e) => set("email", e.target.value)}
|
| 411 |
+
placeholder="john@company.com"
|
| 412 |
+
/>
|
| 413 |
+
{errors.email && <p className="text-sm text-destructive mt-1">{errors.email}</p>}
|
| 414 |
+
</div>
|
| 415 |
+
</div>
|
| 416 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 417 |
+
<div>
|
| 418 |
+
<label className="text-sm font-medium mb-1.5 block" htmlFor="phone">Phone (optional)</label>
|
| 419 |
+
<Input
|
| 420 |
+
id="phone"
|
| 421 |
+
type="tel"
|
| 422 |
+
value={form.phone}
|
| 423 |
+
onChange={(e) => set("phone", e.target.value)}
|
| 424 |
+
placeholder="+1 (555) 000-0000"
|
| 425 |
+
/>
|
| 426 |
+
</div>
|
| 427 |
+
<div>
|
| 428 |
+
<label className="text-sm font-medium mb-1.5 block" htmlFor="company">Company (optional)</label>
|
| 429 |
+
<Input
|
| 430 |
+
id="company"
|
| 431 |
+
value={form.company}
|
| 432 |
+
onChange={(e) => set("company", e.target.value)}
|
| 433 |
+
placeholder="Your Company"
|
| 434 |
+
/>
|
| 435 |
+
</div>
|
| 436 |
+
</div>
|
| 437 |
+
|
| 438 |
+
{/* File upload */}
|
| 439 |
+
<div>
|
| 440 |
+
<label className="text-sm font-medium mb-1.5 block">Attachments (optional)</label>
|
| 441 |
+
<div
|
| 442 |
+
className="border-2 border-dashed rounded-xl p-6 text-center transition-colors hover:border-primary/40 cursor-pointer"
|
| 443 |
+
onDragOver={(e) => e.preventDefault()}
|
| 444 |
+
onDrop={(e) => { e.preventDefault(); addFiles(e.dataTransfer.files) }}
|
| 445 |
+
onClick={() => document.getElementById("file-input")?.click()}
|
| 446 |
+
role="button"
|
| 447 |
+
tabIndex={0}
|
| 448 |
+
onKeyDown={(e) => { if (e.key === "Enter") document.getElementById("file-input")?.click() }}
|
| 449 |
+
>
|
| 450 |
+
<Upload className="h-8 w-8 mx-auto text-muted-foreground mb-2" suppressHydrationWarning />
|
| 451 |
+
<p className="text-sm text-muted-foreground">
|
| 452 |
+
Drag & drop or <span className="text-primary font-medium">browse files</span>
|
| 453 |
+
</p>
|
| 454 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 455 |
+
PDF, DOC, PNG, JPG — max 10 MB, up to 3 files
|
| 456 |
+
</p>
|
| 457 |
+
<input
|
| 458 |
+
id="file-input"
|
| 459 |
+
type="file"
|
| 460 |
+
className="hidden"
|
| 461 |
+
multiple
|
| 462 |
+
accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"
|
| 463 |
+
onChange={(e) => addFiles(e.target.files)}
|
| 464 |
+
/>
|
| 465 |
+
</div>
|
| 466 |
+
{form.files.length > 0 && (
|
| 467 |
+
<div className="mt-3 space-y-2">
|
| 468 |
+
{form.files.map((file, idx) => (
|
| 469 |
+
<div key={idx} className="flex items-center gap-3 bg-muted/50 rounded-lg px-3 py-2 text-sm">
|
| 470 |
+
<FileText className="h-4 w-4 text-muted-foreground shrink-0" suppressHydrationWarning />
|
| 471 |
+
<span className="flex-1 truncate">{file.name}</span>
|
| 472 |
+
<span className="text-xs text-muted-foreground">{(file.size / 1024).toFixed(0)} KB</span>
|
| 473 |
+
<button type="button" onClick={() => removeFile(idx)} className="text-muted-foreground hover:text-destructive">
|
| 474 |
+
<X className="h-4 w-4" suppressHydrationWarning />
|
| 475 |
+
</button>
|
| 476 |
+
</div>
|
| 477 |
+
))}
|
| 478 |
+
</div>
|
| 479 |
+
)}
|
| 480 |
+
</div>
|
| 481 |
+
</fieldset>
|
| 482 |
+
)}
|
| 483 |
+
|
| 484 |
+
{/* Submit error */}
|
| 485 |
+
{submitError && <p className="text-sm text-destructive mt-4">{submitError}</p>}
|
| 486 |
+
|
| 487 |
+
{/* Navigation buttons */}
|
| 488 |
+
<div className="flex justify-between items-center mt-8 pt-6 border-t">
|
| 489 |
+
{step > 1 ? (
|
| 490 |
+
<Button type="button" variant="outline" onClick={back}>
|
| 491 |
+
<ArrowLeft className="mr-2 h-4 w-4" suppressHydrationWarning />
|
| 492 |
+
Back
|
| 493 |
+
</Button>
|
| 494 |
+
) : <span />}
|
| 495 |
+
|
| 496 |
+
{step < TOTAL_STEPS ? (
|
| 497 |
+
<Button type="button" onClick={next}>
|
| 498 |
+
Next
|
| 499 |
+
<ArrowRight className="ml-2 h-4 w-4" suppressHydrationWarning />
|
| 500 |
+
</Button>
|
| 501 |
+
) : (
|
| 502 |
+
<Button type="button" onClick={handleSubmit} disabled={submitting} size="lg">
|
| 503 |
+
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" suppressHydrationWarning />}
|
| 504 |
+
Submit Quote Request
|
| 505 |
+
{!submitting && <ArrowRight className="ml-2 h-4 w-4" suppressHydrationWarning />}
|
| 506 |
+
</Button>
|
| 507 |
+
)}
|
| 508 |
+
</div>
|
| 509 |
+
</CardContent>
|
| 510 |
+
</Card>
|
| 511 |
+
|
| 512 |
+
{/* Trust signals */}
|
| 513 |
+
<div className="flex flex-wrap justify-center gap-6 mt-8 text-sm text-muted-foreground">
|
| 514 |
+
{["Free consultation", "No hidden fees", "Response within 24h", "100% confidential"].map((t) => (
|
| 515 |
+
<span key={t} className="flex items-center gap-1.5">
|
| 516 |
+
<CheckCircle2 className="h-4 w-4 text-green-500" suppressHydrationWarning />
|
| 517 |
+
{t}
|
| 518 |
+
</span>
|
| 519 |
+
))}
|
| 520 |
+
</div>
|
| 521 |
+
</div>
|
| 522 |
+
</div>
|
| 523 |
+
)
|
| 524 |
+
}
|
app/(public)/services/page.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { PackageComparison } from "@/components/services/PackageComparison"
|
| 2 |
+
import { ServiceDescriptions } from "@/components/services/ServiceDescriptions"
|
| 3 |
+
import { ProcessTimeline } from "@/components/services/ProcessTimeline"
|
| 4 |
+
import { FAQSection } from "@/components/services/FAQSection"
|
| 5 |
+
import { AddOnServices } from "@/components/services/AddOnServices"
|
| 6 |
+
|
| 7 |
+
export default function ServicesPage() {
|
| 8 |
+
return (
|
| 9 |
+
<>
|
| 10 |
+
{/* Hero header */}
|
| 11 |
+
<section className="pt-20 pb-10">
|
| 12 |
+
<div className="container">
|
| 13 |
+
<div className="animate-fade-in-up text-center max-w-3xl mx-auto">
|
| 14 |
+
<p className="text-overline text-primary mb-3">Our Services</p>
|
| 15 |
+
<h1 className="sm:text-5xl mb-6">
|
| 16 |
+
Everything you need to{" "}
|
| 17 |
+
<span className="text-primary">grow online</span>
|
| 18 |
+
</h1>
|
| 19 |
+
<p className="text-body text-muted-foreground">
|
| 20 |
+
We provide end-to-end digital solutions — from strategy and design to development and optimization.
|
| 21 |
+
</p>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
</section>
|
| 25 |
+
|
| 26 |
+
<ServiceDescriptions />
|
| 27 |
+
<PackageComparison />
|
| 28 |
+
<ProcessTimeline />
|
| 29 |
+
<AddOnServices />
|
| 30 |
+
<FAQSection />
|
| 31 |
+
</>
|
| 32 |
+
)
|
| 33 |
+
}
|
app/admin/blog/[id]/page.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, use } from 'react';
|
| 4 |
+
import { notFound } from 'next/navigation';
|
| 5 |
+
import BlogForm from '@/components/admin/BlogForm';
|
| 6 |
+
import { Loader2 } from 'lucide-react';
|
| 7 |
+
import { RoleGuard } from "@/components/auth/RoleGuard";
|
| 8 |
+
import { Role } from "@/types/auth";
|
| 9 |
+
import { useSession } from "next-auth/react";
|
| 10 |
+
|
| 11 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api';
|
| 12 |
+
|
| 13 |
+
export default function EditBlogPostPage({ params }: { params: Promise<{ id: string }> }) {
|
| 14 |
+
const { id } = use(params);
|
| 15 |
+
const { data: session } = useSession();
|
| 16 |
+
const [post, setPost] = useState<any>(null);
|
| 17 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 18 |
+
const [error, setError] = useState(false);
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
if (!session?.user?.id) return;
|
| 22 |
+
|
| 23 |
+
const fetchPost = async () => {
|
| 24 |
+
try {
|
| 25 |
+
const res = await fetch(`${API_URL}/admin/blog/${id}`, {
|
| 26 |
+
headers: {
|
| 27 |
+
'Authorization': `Bearer ${session?.user?.id}`
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
if (res.ok) {
|
| 31 |
+
const data = await res.json();
|
| 32 |
+
setPost(data);
|
| 33 |
+
} else {
|
| 34 |
+
setError(true);
|
| 35 |
+
}
|
| 36 |
+
} catch (err) {
|
| 37 |
+
console.error("Error fetching admin post:", err);
|
| 38 |
+
setError(true);
|
| 39 |
+
} finally {
|
| 40 |
+
setIsLoading(false);
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
fetchPost();
|
| 45 |
+
}, [id, session]);
|
| 46 |
+
|
| 47 |
+
if (isLoading) {
|
| 48 |
+
return (
|
| 49 |
+
<div className="h-[60vh] flex items-center justify-center">
|
| 50 |
+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
| 51 |
+
</div>
|
| 52 |
+
);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
if (error || !post) {
|
| 56 |
+
notFound();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
<RoleGuard allowedRoles={[Role.SUPER_ADMIN, Role.EDITOR]}>
|
| 61 |
+
<div className="max-w-5xl mx-auto py-6">
|
| 62 |
+
<BlogForm initialData={post} isEditing />
|
| 63 |
+
</div>
|
| 64 |
+
</RoleGuard>
|
| 65 |
+
);
|
| 66 |
+
}
|
app/admin/blog/new/page.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import BlogForm from '@/components/admin/BlogForm';
|
| 2 |
+
import { RoleGuard } from "@/components/auth/RoleGuard";
|
| 3 |
+
import { Role } from "@/types/auth";
|
| 4 |
+
|
| 5 |
+
export const metadata = {
|
| 6 |
+
title: 'New Blog Post | NexaFlow Admin',
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export default function NewBlogPostPage() {
|
| 10 |
+
return (
|
| 11 |
+
<RoleGuard allowedRoles={[Role.SUPER_ADMIN, Role.EDITOR]}>
|
| 12 |
+
<div className="max-w-5xl mx-auto py-6">
|
| 13 |
+
<BlogForm />
|
| 14 |
+
</div>
|
| 15 |
+
</RoleGuard>
|
| 16 |
+
);
|
| 17 |
+
}
|
app/admin/blog/page.tsx
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react';
|
| 4 |
+
import Link from 'next/link';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Input } from '@/components/ui/input';
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
| 8 |
+
import { Plus, Search, Pencil, Trash2, Globe, FileText, CalendarClock } from 'lucide-react';
|
| 9 |
+
import { format } from 'date-fns';
|
| 10 |
+
import { useSession } from "next-auth/react";
|
| 11 |
+
import { RoleGuard } from "@/components/auth/RoleGuard";
|
| 12 |
+
import { Role } from "@/types/auth";
|
| 13 |
+
|
| 14 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api';
|
| 15 |
+
|
| 16 |
+
export default function AdminBlogPage() {
|
| 17 |
+
const { data: session } = useSession();
|
| 18 |
+
const [posts, setPosts] = useState<any[]>([]);
|
| 19 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 20 |
+
const [searchTerm, setSearchTerm] = useState('');
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
if (session) {
|
| 24 |
+
fetchPosts();
|
| 25 |
+
}
|
| 26 |
+
}, [session]);
|
| 27 |
+
|
| 28 |
+
const fetchPosts = async () => {
|
| 29 |
+
setIsLoading(true);
|
| 30 |
+
try {
|
| 31 |
+
// Using standard fetch since this is an admin dashboard.
|
| 32 |
+
// The Next.js API route will attach the Bearer token or session cookie appropriately.
|
| 33 |
+
const res = await fetch(`${API_URL}/admin/blog`, {
|
| 34 |
+
headers: {
|
| 35 |
+
'Content-Type': 'application/json',
|
| 36 |
+
'Authorization': `Bearer ${session?.user?.id}`
|
| 37 |
+
}
|
| 38 |
+
});
|
| 39 |
+
if (res.ok) {
|
| 40 |
+
const data = await res.json();
|
| 41 |
+
setPosts(data);
|
| 42 |
+
} else {
|
| 43 |
+
console.error("Failed to fetch admin posts");
|
| 44 |
+
}
|
| 45 |
+
} catch (error) {
|
| 46 |
+
console.error("Error fetching posts:", error);
|
| 47 |
+
} finally {
|
| 48 |
+
setIsLoading(false);
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const handleDelete = async (id: string, title: string) => {
|
| 53 |
+
if (!confirm(`Are you sure you want to delete "${title}"?`)) return;
|
| 54 |
+
|
| 55 |
+
try {
|
| 56 |
+
const res = await fetch(`${API_URL}/admin/blog/${id}`, {
|
| 57 |
+
method: 'DELETE',
|
| 58 |
+
headers: {
|
| 59 |
+
'Authorization': `Bearer ${session?.user?.id}`
|
| 60 |
+
}
|
| 61 |
+
});
|
| 62 |
+
if (res.ok) {
|
| 63 |
+
setPosts(posts.filter(post => post.id !== id));
|
| 64 |
+
} else {
|
| 65 |
+
alert("Failed to delete post");
|
| 66 |
+
}
|
| 67 |
+
} catch (error) {
|
| 68 |
+
console.error("Error deleting post:", error);
|
| 69 |
+
alert("Error deleting post");
|
| 70 |
+
}
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
const filteredPosts = posts.filter(post =>
|
| 74 |
+
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 75 |
+
post.status.toLowerCase().includes(searchTerm.toLowerCase())
|
| 76 |
+
);
|
| 77 |
+
|
| 78 |
+
const getStatusIcon = (status: string) => {
|
| 79 |
+
switch (status) {
|
| 80 |
+
case 'PUBLISHED': return <Globe className="h-4 w-4 text-green-500" />;
|
| 81 |
+
case 'SCHEDULED': return <CalendarClock className="h-4 w-4 text-blue-500" />;
|
| 82 |
+
case 'DRAFT': return <FileText className="h-4 w-4 text-gray-500" />;
|
| 83 |
+
default: return null;
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
return (
|
| 88 |
+
<RoleGuard allowedRoles={[Role.SUPER_ADMIN, Role.EDITOR]}>
|
| 89 |
+
<div className="space-y-6">
|
| 90 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
| 91 |
+
<div>
|
| 92 |
+
<h2 className="text-3xl font-bold tracking-tight">Blog Articles</h2>
|
| 93 |
+
<p className="text-muted-foreground">Manage your content, drafts, and scheduled posts.</p>
|
| 94 |
+
</div>
|
| 95 |
+
<Button asChild>
|
| 96 |
+
<Link href="/admin/blog/new">
|
| 97 |
+
<Plus className="mr-2 h-4 w-4" /> New Article
|
| 98 |
+
</Link>
|
| 99 |
+
</Button>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<Card>
|
| 103 |
+
<CardHeader className="pb-3 border-b border-border/40">
|
| 104 |
+
<div className="flex items-center justify-between">
|
| 105 |
+
<CardTitle>All Posts</CardTitle>
|
| 106 |
+
<div className="relative w-64">
|
| 107 |
+
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
| 108 |
+
<Input
|
| 109 |
+
type="search"
|
| 110 |
+
placeholder="Search articles..."
|
| 111 |
+
className="pl-9 h-9"
|
| 112 |
+
value={searchTerm}
|
| 113 |
+
onChange={(e) => setSearchTerm(e.target.value)}
|
| 114 |
+
/>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
</CardHeader>
|
| 118 |
+
<CardContent className="p-0">
|
| 119 |
+
{isLoading ? (
|
| 120 |
+
<div className="p-8 text-center text-muted-foreground">Loading posts...</div>
|
| 121 |
+
) : filteredPosts.length === 0 ? (
|
| 122 |
+
<div className="p-8 text-center text-muted-foreground">No posts found matching the criteria.</div>
|
| 123 |
+
) : (
|
| 124 |
+
<div className="overflow-x-auto">
|
| 125 |
+
<table className="w-full text-sm text-left">
|
| 126 |
+
<thead className="text-xs text-muted-foreground uppercase bg-muted/50 border-b">
|
| 127 |
+
<tr>
|
| 128 |
+
<th className="px-6 py-3 font-medium">Title</th>
|
| 129 |
+
<th className="px-6 py-3 font-medium">Status</th>
|
| 130 |
+
<th className="px-6 py-3 font-medium">Author</th>
|
| 131 |
+
<th className="px-6 py-3 font-medium">Date</th>
|
| 132 |
+
<th className="px-6 py-3 font-medium text-right">Actions</th>
|
| 133 |
+
</tr>
|
| 134 |
+
</thead>
|
| 135 |
+
<tbody>
|
| 136 |
+
{filteredPosts.map((post) => (
|
| 137 |
+
<tr key={post.id} className="border-b last:border-0 hover:bg-muted/30 transition-colors">
|
| 138 |
+
<td className="px-6 py-4">
|
| 139 |
+
<div className="font-medium text-foreground">{post.title}</div>
|
| 140 |
+
<div className="text-xs text-muted-foreground truncate max-w-xs">{post.slug}</div>
|
| 141 |
+
</td>
|
| 142 |
+
<td className="px-6 py-4">
|
| 143 |
+
<div className="flex items-center gap-1.5 text-xs font-medium">
|
| 144 |
+
{getStatusIcon(post.status)}
|
| 145 |
+
<span>{post.status}</span>
|
| 146 |
+
</div>
|
| 147 |
+
</td>
|
| 148 |
+
<td className="px-6 py-4 text-muted-foreground">
|
| 149 |
+
{post.author?.name || 'Unknown'}
|
| 150 |
+
</td>
|
| 151 |
+
<td className="px-6 py-4 text-muted-foreground">
|
| 152 |
+
{post.publishedAt ? format(new Date(post.publishedAt), 'MMM d, yyyy') : 'Not set'}
|
| 153 |
+
</td>
|
| 154 |
+
<td className="px-6 py-4 text-right">
|
| 155 |
+
<div className="flex justify-end gap-2">
|
| 156 |
+
<Button variant="ghost" size="icon" asChild>
|
| 157 |
+
<Link href={`/admin/blog/${post.id}`}>
|
| 158 |
+
<Pencil className="h-4 w-4" />
|
| 159 |
+
<span className="sr-only">Edit</span>
|
| 160 |
+
</Link>
|
| 161 |
+
</Button>
|
| 162 |
+
<Button variant="ghost" size="icon" className="text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => handleDelete(post.id, post.title)}>
|
| 163 |
+
<Trash2 className="h-4 w-4" />
|
| 164 |
+
<span className="sr-only">Delete</span>
|
| 165 |
+
</Button>
|
| 166 |
+
</div>
|
| 167 |
+
</td>
|
| 168 |
+
</tr>
|
| 169 |
+
))}
|
| 170 |
+
</tbody>
|
| 171 |
+
</table>
|
| 172 |
+
</div>
|
| 173 |
+
)}
|
| 174 |
+
</CardContent>
|
| 175 |
+
</Card>
|
| 176 |
+
</div>
|
| 177 |
+
</RoleGuard>
|
| 178 |
+
);
|
| 179 |
+
}
|
app/admin/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { auth, signOut } from "@/lib/auth"
|
| 2 |
+
import { redirect } from "next/navigation"
|
| 3 |
+
import { Button } from "@/components/ui/button"
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
| 5 |
+
import { MessageSquare, FolderGit2, Users } from "lucide-react"
|
| 6 |
+
import Link from "next/link"
|
| 7 |
+
|
| 8 |
+
export default async function DashboardPage() {
|
| 9 |
+
const session = await auth()
|
| 10 |
+
|
| 11 |
+
if (!session?.user) {
|
| 12 |
+
redirect("/admin/login")
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
| 17 |
+
<div className="flex items-center justify-between">
|
| 18 |
+
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
|
| 19 |
+
<form
|
| 20 |
+
action={async () => {
|
| 21 |
+
"use server"
|
| 22 |
+
await signOut()
|
| 23 |
+
}}
|
| 24 |
+
>
|
| 25 |
+
<Button variant="outline">Sign Out</Button>
|
| 26 |
+
</form>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
| 30 |
+
<Card>
|
| 31 |
+
<CardHeader>
|
| 32 |
+
<CardTitle>Welcome back, {session.user.name || session.user.email}</CardTitle>
|
| 33 |
+
</CardHeader>
|
| 34 |
+
<CardContent>
|
| 35 |
+
<p className="text-sm text-gray-500">
|
| 36 |
+
Role: <span className="font-mono">{session.user.role}</span>
|
| 37 |
+
</p>
|
| 38 |
+
</CardContent>
|
| 39 |
+
</Card>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<h2 className="text-2xl font-bold mt-12 mb-6">Quick Links</h2>
|
| 43 |
+
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
| 44 |
+
<Link href="/admin/inquiries">
|
| 45 |
+
<Card className="hover:border-primary/50 transition-colors h-full flex flex-col items-center justify-center p-6 text-center">
|
| 46 |
+
<MessageSquare className="h-12 w-12 text-primary mb-4" />
|
| 47 |
+
<CardTitle>Inquiries</CardTitle>
|
| 48 |
+
<CardDescription className="mt-2">Manage quote requests and leads</CardDescription>
|
| 49 |
+
</Card>
|
| 50 |
+
</Link>
|
| 51 |
+
|
| 52 |
+
<Link href="/admin/blog">
|
| 53 |
+
<Card className="hover:border-primary/50 transition-colors h-full flex flex-col items-center justify-center p-6 text-center">
|
| 54 |
+
<FolderGit2 className="h-12 w-12 text-primary mb-4" />
|
| 55 |
+
<CardTitle>Blog Posts</CardTitle>
|
| 56 |
+
<CardDescription className="mt-2">Write, edit, and schedule articles</CardDescription>
|
| 57 |
+
</Card>
|
| 58 |
+
</Link>
|
| 59 |
+
|
| 60 |
+
<Link href="/admin/portfolio">
|
| 61 |
+
<Card className="hover:border-primary/50 transition-colors h-full flex flex-col items-center justify-center p-6 text-center">
|
| 62 |
+
<FolderGit2 className="h-12 w-12 text-primary mb-4" />
|
| 63 |
+
<CardTitle>Portfolio</CardTitle>
|
| 64 |
+
<CardDescription className="mt-2">Manage case studies and projects</CardDescription>
|
| 65 |
+
</Card>
|
| 66 |
+
</Link>
|
| 67 |
+
|
| 68 |
+
<Link href="/admin/testimonials">
|
| 69 |
+
<Card className="hover:border-primary/50 transition-colors h-full flex flex-col items-center justify-center p-6 text-center">
|
| 70 |
+
<Users className="h-12 w-12 text-primary mb-4" />
|
| 71 |
+
<CardTitle>Testimonials</CardTitle>
|
| 72 |
+
<CardDescription className="mt-2">Approve and manage client reviews</CardDescription>
|
| 73 |
+
</Card>
|
| 74 |
+
</Link>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
)
|
| 78 |
+
}
|
app/admin/forgot-password/page.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState } from "react"
|
| 4 |
+
import { Button } from "@/components/ui/button"
|
| 5 |
+
import { Input } from "@/components/ui/input"
|
| 6 |
+
import { Label } from "@/components/ui/label"
|
| 7 |
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
| 8 |
+
import Link from "next/link"
|
| 9 |
+
|
| 10 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api';
|
| 11 |
+
|
| 12 |
+
export default function ForgotPasswordPage() {
|
| 13 |
+
const [email, setEmail] = useState("")
|
| 14 |
+
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
|
| 15 |
+
|
| 16 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 17 |
+
e.preventDefault()
|
| 18 |
+
setStatus("loading")
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
const res = await fetch(`${API_URL}/auth/forgot-password`, {
|
| 22 |
+
method: "POST",
|
| 23 |
+
headers: { "Content-Type": "application/json" },
|
| 24 |
+
body: JSON.stringify({ email }),
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
if (res.ok) {
|
| 28 |
+
setStatus("success")
|
| 29 |
+
} else {
|
| 30 |
+
setStatus("error")
|
| 31 |
+
}
|
| 32 |
+
} catch (error) {
|
| 33 |
+
setStatus("error")
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<div className="flex min-h-screen items-center justify-center bg-muted/50 p-4">
|
| 39 |
+
<Card className="w-full max-w-md">
|
| 40 |
+
<CardHeader>
|
| 41 |
+
<CardTitle className="text-2xl font-bold">Forgot Password</CardTitle>
|
| 42 |
+
<CardDescription>
|
| 43 |
+
Enter your email address and we will send you a link to reset your password.
|
| 44 |
+
</CardDescription>
|
| 45 |
+
</CardHeader>
|
| 46 |
+
<CardContent>
|
| 47 |
+
{status === "success" ? (
|
| 48 |
+
<div className="bg-green-100 border border-green-200 text-green-700 px-4 py-3 rounded relative" role="alert">
|
| 49 |
+
<strong className="font-bold">Check your email!</strong>
|
| 50 |
+
<span className="block sm:inline"> We have sent a password reset link to your address.</span>
|
| 51 |
+
</div>
|
| 52 |
+
) : (
|
| 53 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 54 |
+
<div className="space-y-2">
|
| 55 |
+
<Label htmlFor="email">Email</Label>
|
| 56 |
+
<Input
|
| 57 |
+
id="email"
|
| 58 |
+
type="email"
|
| 59 |
+
placeholder="admin@example.com"
|
| 60 |
+
required
|
| 61 |
+
value={email}
|
| 62 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 63 |
+
disabled={status === "loading"}
|
| 64 |
+
/>
|
| 65 |
+
</div>
|
| 66 |
+
<Button type="submit" className="w-full" disabled={status === "loading"}>
|
| 67 |
+
{status === "loading" ? "Sending..." : "Send Reset Link"}
|
| 68 |
+
</Button>
|
| 69 |
+
</form>
|
| 70 |
+
)}
|
| 71 |
+
</CardContent>
|
| 72 |
+
<CardFooter className="flex justify-center">
|
| 73 |
+
<Link href="/admin/login" className="text-sm text-primary hover:underline">
|
| 74 |
+
Back to Login
|
| 75 |
+
</Link>
|
| 76 |
+
</CardFooter>
|
| 77 |
+
</Card>
|
| 78 |
+
</div>
|
| 79 |
+
)
|
| 80 |
+
}
|
app/admin/inquiries/[id]/page.tsx
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react"
|
| 4 |
+
import { useSession } from "next-auth/react"
|
| 5 |
+
import { useRouter, useParams } from "next/navigation"
|
| 6 |
+
import { format } from "date-fns"
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
| 8 |
+
import { Input } from "@/components/ui/input"
|
| 9 |
+
import { Label } from "@/components/ui/label"
|
| 10 |
+
import { Badge } from "@/components/ui/badge"
|
| 11 |
+
import { Button } from "@/components/ui/button"
|
| 12 |
+
import { Textarea } from "@/components/ui/textarea"
|
| 13 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
| 14 |
+
import { Loader2, ArrowLeft, Mail, Phone, Building, Calendar, DollarSign, Clock, Save } from "lucide-react"
|
| 15 |
+
import Link from "next/link"
|
| 16 |
+
import { toast } from "sonner"
|
| 17 |
+
|
| 18 |
+
interface Inquiry {
|
| 19 |
+
id: string
|
| 20 |
+
name: string
|
| 21 |
+
email: string
|
| 22 |
+
phone: string | null
|
| 23 |
+
company: string | null
|
| 24 |
+
projectType: string | null
|
| 25 |
+
budget: string | null
|
| 26 |
+
timeline: string | null
|
| 27 |
+
description: string
|
| 28 |
+
status: string
|
| 29 |
+
notes: string | null
|
| 30 |
+
createdAt: string
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const STATUS_OPTIONS = [
|
| 34 |
+
{ value: "NEW", label: "New Request" },
|
| 35 |
+
{ value: "IN_PROGRESS", label: "In Progress" },
|
| 36 |
+
{ value: "QUOTED", label: "Quoted" },
|
| 37 |
+
{ value: "WON", label: "Deal Won" },
|
| 38 |
+
{ value: "LOST", label: "Closed / Lost" },
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
export default function InquiryDetailPage() {
|
| 42 |
+
const { data: session } = useSession()
|
| 43 |
+
const router = useRouter()
|
| 44 |
+
const params = useParams()
|
| 45 |
+
const inquiryId = params.id as string
|
| 46 |
+
|
| 47 |
+
const [inquiry, setInquiry] = useState<Inquiry | null>(null)
|
| 48 |
+
const [loading, setLoading] = useState(true)
|
| 49 |
+
const [saving, setSaving] = useState(false)
|
| 50 |
+
|
| 51 |
+
// Form state
|
| 52 |
+
const [status, setStatus] = useState("")
|
| 53 |
+
const [notes, setNotes] = useState("")
|
| 54 |
+
|
| 55 |
+
useEffect(() => {
|
| 56 |
+
if (session && inquiryId) {
|
| 57 |
+
fetchInquiry()
|
| 58 |
+
}
|
| 59 |
+
}, [session, inquiryId])
|
| 60 |
+
|
| 61 |
+
const fetchInquiry = async () => {
|
| 62 |
+
try {
|
| 63 |
+
const res = await fetch(`http://localhost:5000/api/admin/inquiries/${inquiryId}`, {
|
| 64 |
+
headers: {
|
| 65 |
+
Authorization: `Bearer ${session?.user?.id}`,
|
| 66 |
+
},
|
| 67 |
+
})
|
| 68 |
+
if (res.ok) {
|
| 69 |
+
const data = await res.json()
|
| 70 |
+
setInquiry(data)
|
| 71 |
+
setStatus(data.status)
|
| 72 |
+
setNotes(data.notes || "")
|
| 73 |
+
} else {
|
| 74 |
+
toast.error("Inquiry not found")
|
| 75 |
+
router.push("/admin/inquiries")
|
| 76 |
+
}
|
| 77 |
+
} catch (error) {
|
| 78 |
+
console.error("Failed to fetch inquiry:", error)
|
| 79 |
+
toast.error("Failed to load details")
|
| 80 |
+
} finally {
|
| 81 |
+
setLoading(false)
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const handleSave = async () => {
|
| 86 |
+
setSaving(true)
|
| 87 |
+
try {
|
| 88 |
+
const res = await fetch(`http://localhost:5000/api/admin/inquiries/${inquiryId}`, {
|
| 89 |
+
method: "PATCH",
|
| 90 |
+
headers: {
|
| 91 |
+
"Content-Type": "application/json",
|
| 92 |
+
Authorization: `Bearer ${session?.user?.id}`,
|
| 93 |
+
},
|
| 94 |
+
body: JSON.stringify({ status, notes }),
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
if (res.ok) {
|
| 98 |
+
toast.success("Inquiry updated successfully")
|
| 99 |
+
const updated = await res.json()
|
| 100 |
+
setInquiry(updated)
|
| 101 |
+
} else {
|
| 102 |
+
toast.error("Failed to update inquiry")
|
| 103 |
+
}
|
| 104 |
+
} catch (error) {
|
| 105 |
+
console.error("Failed to save:", error)
|
| 106 |
+
toast.error("An error occurred")
|
| 107 |
+
} finally {
|
| 108 |
+
setSaving(false)
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
if (loading) {
|
| 113 |
+
return (
|
| 114 |
+
<div className="flex h-[50vh] items-center justify-center">
|
| 115 |
+
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
| 116 |
+
</div>
|
| 117 |
+
)
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
if (!inquiry) return null
|
| 121 |
+
|
| 122 |
+
return (
|
| 123 |
+
<div className="space-y-6 max-w-5xl">
|
| 124 |
+
<div className="flex items-center gap-4">
|
| 125 |
+
<Button variant="outline" size="icon" asChild>
|
| 126 |
+
<Link href="/admin/inquiries">
|
| 127 |
+
<ArrowLeft className="h-4 w-4" />
|
| 128 |
+
</Link>
|
| 129 |
+
</Button>
|
| 130 |
+
<div>
|
| 131 |
+
<h1 className="text-3xl font-bold tracking-tight">Inquiry Details</h1>
|
| 132 |
+
<p className="text-muted-foreground">
|
| 133 |
+
Submitted {format(new Date(inquiry.createdAt), "PPP 'at' p")}
|
| 134 |
+
</p>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<div className="grid gap-6 md:grid-cols-3">
|
| 139 |
+
{/* Main Client Info */}
|
| 140 |
+
<div className="md:col-span-2 space-y-6">
|
| 141 |
+
<Card>
|
| 142 |
+
<CardHeader>
|
| 143 |
+
<CardTitle>Client Information</CardTitle>
|
| 144 |
+
</CardHeader>
|
| 145 |
+
<CardContent className="grid gap-4 sm:grid-cols-2">
|
| 146 |
+
<div className="space-y-1">
|
| 147 |
+
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
| 148 |
+
Name
|
| 149 |
+
</span>
|
| 150 |
+
<p className="font-medium">{inquiry.name}</p>
|
| 151 |
+
</div>
|
| 152 |
+
<div className="space-y-1">
|
| 153 |
+
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
| 154 |
+
<Mail className="h-4 w-4" /> Email
|
| 155 |
+
</span>
|
| 156 |
+
<p className="font-medium">
|
| 157 |
+
<a href={`mailto:${inquiry.email}`} className="text-primary hover:underline">
|
| 158 |
+
{inquiry.email}
|
| 159 |
+
</a>
|
| 160 |
+
</p>
|
| 161 |
+
</div>
|
| 162 |
+
<div className="space-y-1">
|
| 163 |
+
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
| 164 |
+
<Phone className="h-4 w-4" /> Phone
|
| 165 |
+
</span>
|
| 166 |
+
<p className="font-medium">{inquiry.phone || "Not provided"}</p>
|
| 167 |
+
</div>
|
| 168 |
+
<div className="space-y-1">
|
| 169 |
+
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
| 170 |
+
<Building className="h-4 w-4" /> Company
|
| 171 |
+
</span>
|
| 172 |
+
<p className="font-medium">{inquiry.company || "Not provided"}</p>
|
| 173 |
+
</div>
|
| 174 |
+
</CardContent>
|
| 175 |
+
</Card>
|
| 176 |
+
|
| 177 |
+
<Card>
|
| 178 |
+
<CardHeader>
|
| 179 |
+
<CardTitle>Project Requirements</CardTitle>
|
| 180 |
+
</CardHeader>
|
| 181 |
+
<CardContent className="space-y-6">
|
| 182 |
+
<div className="grid gap-4 sm:grid-cols-3 bg-muted/50 p-4 rounded-lg">
|
| 183 |
+
<div>
|
| 184 |
+
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2 mb-1">
|
| 185 |
+
Type
|
| 186 |
+
</span>
|
| 187 |
+
<p className="font-medium capitalize">{inquiry.projectType || "General"}</p>
|
| 188 |
+
</div>
|
| 189 |
+
<div>
|
| 190 |
+
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2 mb-1">
|
| 191 |
+
<DollarSign className="h-4 w-4" /> Budget
|
| 192 |
+
</span>
|
| 193 |
+
<p className="font-medium">{inquiry.budget || "Not specified"}</p>
|
| 194 |
+
</div>
|
| 195 |
+
<div>
|
| 196 |
+
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2 mb-1">
|
| 197 |
+
<Clock className="h-4 w-4" /> Timeline
|
| 198 |
+
</span>
|
| 199 |
+
<p className="font-medium">{inquiry.timeline || "Flexible"}</p>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<div className="space-y-2">
|
| 204 |
+
<h4 className="font-medium">Project Description</h4>
|
| 205 |
+
<div className="p-4 bg-muted/30 rounded-md whitespace-pre-wrap text-sm border">
|
| 206 |
+
{inquiry.description}
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</CardContent>
|
| 210 |
+
</Card>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
{/* Admin Controls */}
|
| 214 |
+
<div className="space-y-6">
|
| 215 |
+
<Card className="border-primary/20 bg-primary/5">
|
| 216 |
+
<CardHeader>
|
| 217 |
+
<CardTitle>Management</CardTitle>
|
| 218 |
+
<CardDescription>Update status and add internal notes.</CardDescription>
|
| 219 |
+
</CardHeader>
|
| 220 |
+
<CardContent className="space-y-6">
|
| 221 |
+
<div className="space-y-2">
|
| 222 |
+
<Label htmlFor="status">Current Status</Label>
|
| 223 |
+
<Select value={status} onValueChange={setStatus}>
|
| 224 |
+
<SelectTrigger id="status" className="bg-background">
|
| 225 |
+
<SelectValue placeholder="Select status" />
|
| 226 |
+
</SelectTrigger>
|
| 227 |
+
<SelectContent>
|
| 228 |
+
{STATUS_OPTIONS.map((opt) => (
|
| 229 |
+
<SelectItem key={opt.value} value={opt.value}>
|
| 230 |
+
{opt.label}
|
| 231 |
+
</SelectItem>
|
| 232 |
+
))}
|
| 233 |
+
</SelectContent>
|
| 234 |
+
</Select>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<div className="space-y-2">
|
| 238 |
+
<Label htmlFor="notes">Internal Notes (Not visible to client)</Label>
|
| 239 |
+
<Textarea
|
| 240 |
+
id="notes"
|
| 241 |
+
placeholder="Add background info, meeting notes, etc..."
|
| 242 |
+
className="min-h-[150px] bg-background"
|
| 243 |
+
value={notes}
|
| 244 |
+
onChange={(e) => setNotes(e.target.value)}
|
| 245 |
+
/>
|
| 246 |
+
</div>
|
| 247 |
+
|
| 248 |
+
<Button
|
| 249 |
+
className="w-full"
|
| 250 |
+
onClick={handleSave}
|
| 251 |
+
disabled={saving || (status === inquiry.status && notes === (inquiry.notes || ""))}
|
| 252 |
+
>
|
| 253 |
+
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
| 254 |
+
Save Changes
|
| 255 |
+
</Button>
|
| 256 |
+
</CardContent>
|
| 257 |
+
</Card>
|
| 258 |
+
|
| 259 |
+
<div className="text-xs text-muted-foreground text-center">
|
| 260 |
+
<p>Inquiry ID: <span className="font-mono">{inquiry.id}</span></p>
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
)
|
| 266 |
+
}
|
app/admin/inquiries/page.tsx
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react"
|
| 4 |
+
import { useSession } from "next-auth/react"
|
| 5 |
+
import { formatDistanceToNow } from "date-fns"
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
| 7 |
+
import { Input } from "@/components/ui/input"
|
| 8 |
+
import { Badge } from "@/components/ui/badge"
|
| 9 |
+
import { Button } from "@/components/ui/button"
|
| 10 |
+
import { Loader2, Search, Filter } from "lucide-react"
|
| 11 |
+
import Link from "next/link"
|
| 12 |
+
|
| 13 |
+
interface Inquiry {
|
| 14 |
+
id: string
|
| 15 |
+
name: string
|
| 16 |
+
email: string
|
| 17 |
+
projectType: string
|
| 18 |
+
budget: string | null
|
| 19 |
+
status: string
|
| 20 |
+
createdAt: string
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const statusColors: Record<string, string> = {
|
| 24 |
+
NEW: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
|
| 25 |
+
IN_PROGRESS: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300",
|
| 26 |
+
QUOTED: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300",
|
| 27 |
+
WON: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
|
| 28 |
+
LOST: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export default function InquiriesPage() {
|
| 32 |
+
const { data: session } = useSession()
|
| 33 |
+
const [inquiries, setInquiries] = useState<Inquiry[]>([])
|
| 34 |
+
const [loading, setLoading] = useState(true)
|
| 35 |
+
const [searchTerm, setSearchTerm] = useState("")
|
| 36 |
+
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
if (session) {
|
| 39 |
+
fetchInquiries()
|
| 40 |
+
}
|
| 41 |
+
}, [session])
|
| 42 |
+
|
| 43 |
+
const fetchInquiries = async () => {
|
| 44 |
+
try {
|
| 45 |
+
const res = await fetch("http://localhost:5000/api/admin/inquiries", {
|
| 46 |
+
headers: {
|
| 47 |
+
Authorization: `Bearer ${session?.user?.id}`, // using user id as mock token for now
|
| 48 |
+
},
|
| 49 |
+
})
|
| 50 |
+
if (res.ok) {
|
| 51 |
+
const data = await res.json()
|
| 52 |
+
setInquiries(data)
|
| 53 |
+
}
|
| 54 |
+
} catch (error) {
|
| 55 |
+
console.error("Failed to fetch inquiries:", error)
|
| 56 |
+
} finally {
|
| 57 |
+
setLoading(false)
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const filteredInquiries = inquiries.filter(
|
| 62 |
+
(inq) =>
|
| 63 |
+
inq.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 64 |
+
inq.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 65 |
+
(inq.projectType && inq.projectType.toLowerCase().includes(searchTerm.toLowerCase()))
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
if (loading) {
|
| 69 |
+
return (
|
| 70 |
+
<div className="flex h-[50vh] items-center justify-center">
|
| 71 |
+
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
| 72 |
+
</div>
|
| 73 |
+
)
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return (
|
| 77 |
+
<div className="space-y-6">
|
| 78 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 79 |
+
<div>
|
| 80 |
+
<h1 className="text-3xl font-bold tracking-tight">Inquiry Management</h1>
|
| 81 |
+
<p className="text-muted-foreground">Manage and track client quote requests.</p>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<Card>
|
| 86 |
+
<CardHeader>
|
| 87 |
+
<div className="flex items-center space-x-2">
|
| 88 |
+
<Search className="h-4 w-4 text-muted-foreground" />
|
| 89 |
+
<Input
|
| 90 |
+
placeholder="Search by name, email, or project type..."
|
| 91 |
+
value={searchTerm}
|
| 92 |
+
onChange={(e) => setSearchTerm(e.target.value)}
|
| 93 |
+
className="max-w-sm"
|
| 94 |
+
/>
|
| 95 |
+
</div>
|
| 96 |
+
</CardHeader>
|
| 97 |
+
<CardContent>
|
| 98 |
+
<div className="rounded-md border">
|
| 99 |
+
<div className="grid grid-cols-12 gap-4 border-b bg-muted/50 p-4 font-medium text-sm text-muted-foreground">
|
| 100 |
+
<div className="col-span-3">Client</div>
|
| 101 |
+
<div className="col-span-2 hidden sm:block">Project</div>
|
| 102 |
+
<div className="col-span-2 hidden md:block">Budget</div>
|
| 103 |
+
<div className="col-span-2">Date</div>
|
| 104 |
+
<div className="col-span-2">Status</div>
|
| 105 |
+
<div className="col-span-1 text-right">Actions</div>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{filteredInquiries.length === 0 ? (
|
| 109 |
+
<div className="p-8 text-center text-muted-foreground">
|
| 110 |
+
No inquiries found.
|
| 111 |
+
</div>
|
| 112 |
+
) : (
|
| 113 |
+
<div className="divide-y">
|
| 114 |
+
{filteredInquiries.map((inquiry) => (
|
| 115 |
+
<div key={inquiry.id} className="grid grid-cols-12 items-center gap-4 p-4 text-sm hover:bg-muted/50 transition-colors">
|
| 116 |
+
<div className="col-span-3">
|
| 117 |
+
<div className="font-medium truncate">{inquiry.name}</div>
|
| 118 |
+
<div className="text-xs text-muted-foreground truncate">{inquiry.email}</div>
|
| 119 |
+
</div>
|
| 120 |
+
<div className="col-span-2 hidden sm:block truncate capitalize">
|
| 121 |
+
{inquiry.projectType || '—'}
|
| 122 |
+
</div>
|
| 123 |
+
<div className="col-span-2 hidden md:block text-muted-foreground">
|
| 124 |
+
{inquiry.budget || '—'}
|
| 125 |
+
</div>
|
| 126 |
+
<div className="col-span-2 text-muted-foreground">
|
| 127 |
+
{formatDistanceToNow(new Date(inquiry.createdAt), { addSuffix: true })}
|
| 128 |
+
</div>
|
| 129 |
+
<div className="col-span-2">
|
| 130 |
+
<Badge variant="secondary" className={`${statusColors[inquiry.status] || ''} hover:bg-opacity-80`}>
|
| 131 |
+
{inquiry.status.replace('_', ' ')}
|
| 132 |
+
</Badge>
|
| 133 |
+
</div>
|
| 134 |
+
<div className="col-span-1 text-right">
|
| 135 |
+
<Button variant="ghost" size="sm" asChild>
|
| 136 |
+
<Link href={`/admin/inquiries/${inquiry.id}`}>
|
| 137 |
+
View
|
| 138 |
+
</Link>
|
| 139 |
+
</Button>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
))}
|
| 143 |
+
</div>
|
| 144 |
+
)}
|
| 145 |
+
</div>
|
| 146 |
+
</CardContent>
|
| 147 |
+
</Card>
|
| 148 |
+
</div>
|
| 149 |
+
)
|
| 150 |
+
}
|
app/admin/login/page.tsx
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState } from "react"
|
| 4 |
+
import { useForm } from "react-hook-form"
|
| 5 |
+
import { zodResolver } from "@hookform/resolvers/zod"
|
| 6 |
+
import * as z from "zod"
|
| 7 |
+
import { signIn } from "next-auth/react"
|
| 8 |
+
import { useRouter } from "next/navigation"
|
| 9 |
+
import { Loader2 } from "lucide-react"
|
| 10 |
+
import Link from "next/link"
|
| 11 |
+
|
| 12 |
+
import { Button } from "@/components/ui/button"
|
| 13 |
+
import {
|
| 14 |
+
Form,
|
| 15 |
+
FormControl,
|
| 16 |
+
FormField,
|
| 17 |
+
FormItem,
|
| 18 |
+
FormLabel,
|
| 19 |
+
FormMessage,
|
| 20 |
+
} from "@/components/ui/form"
|
| 21 |
+
import { Input } from "@/components/ui/input"
|
| 22 |
+
import {
|
| 23 |
+
Card,
|
| 24 |
+
CardContent,
|
| 25 |
+
CardDescription,
|
| 26 |
+
CardHeader,
|
| 27 |
+
CardTitle,
|
| 28 |
+
} from "@/components/ui/card"
|
| 29 |
+
|
| 30 |
+
const formSchema = z.object({
|
| 31 |
+
email: z.string().email("Invalid email address"),
|
| 32 |
+
password: z.string().min(1, "Password is required"),
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
export default function LoginPage() {
|
| 36 |
+
const router = useRouter()
|
| 37 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 38 |
+
const [error, setError] = useState<string | null>(null)
|
| 39 |
+
|
| 40 |
+
const form = useForm<z.infer<typeof formSchema>>({
|
| 41 |
+
resolver: zodResolver(formSchema),
|
| 42 |
+
defaultValues: {
|
| 43 |
+
email: "",
|
| 44 |
+
password: "",
|
| 45 |
+
},
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
async function onSubmit(values: z.infer<typeof formSchema>) {
|
| 49 |
+
setIsLoading(true)
|
| 50 |
+
setError(null)
|
| 51 |
+
|
| 52 |
+
try {
|
| 53 |
+
const result = await signIn("credentials", {
|
| 54 |
+
email: values.email,
|
| 55 |
+
password: values.password,
|
| 56 |
+
redirect: false,
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
if (result?.error) {
|
| 60 |
+
setError("Invalid email or password")
|
| 61 |
+
return
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
router.push("/admin/dashboard")
|
| 65 |
+
router.refresh()
|
| 66 |
+
} catch (error) {
|
| 67 |
+
setError("Something went wrong. Please try again.")
|
| 68 |
+
} finally {
|
| 69 |
+
setIsLoading(false)
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-4 dark:bg-gray-900">
|
| 75 |
+
<Card className="w-full max-w-md">
|
| 76 |
+
<CardHeader className="space-y-1">
|
| 77 |
+
<CardTitle className="text-2xl font-bold text-center">
|
| 78 |
+
Admin Login
|
| 79 |
+
</CardTitle>
|
| 80 |
+
<CardDescription className="text-center">
|
| 81 |
+
Enter your credentials to access the dashboard
|
| 82 |
+
</CardDescription>
|
| 83 |
+
</CardHeader>
|
| 84 |
+
<CardContent>
|
| 85 |
+
<Form {...form}>
|
| 86 |
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
| 87 |
+
<FormField
|
| 88 |
+
control={form.control}
|
| 89 |
+
name="email"
|
| 90 |
+
render={({ field }) => (
|
| 91 |
+
<FormItem>
|
| 92 |
+
<FormLabel>Email</FormLabel>
|
| 93 |
+
<FormControl>
|
| 94 |
+
<Input placeholder="admin@example.com" {...field} />
|
| 95 |
+
</FormControl>
|
| 96 |
+
<FormMessage />
|
| 97 |
+
</FormItem>
|
| 98 |
+
)}
|
| 99 |
+
/>
|
| 100 |
+
<FormField
|
| 101 |
+
control={form.control}
|
| 102 |
+
name="password"
|
| 103 |
+
render={({ field }) => (
|
| 104 |
+
<FormItem>
|
| 105 |
+
<FormLabel>Password</FormLabel>
|
| 106 |
+
<FormControl>
|
| 107 |
+
<Input type="password" placeholder="••••••" {...field} />
|
| 108 |
+
</FormControl>
|
| 109 |
+
<FormMessage />
|
| 110 |
+
</FormItem>
|
| 111 |
+
)}
|
| 112 |
+
/>
|
| 113 |
+
<div className="flex justify-end">
|
| 114 |
+
<Link
|
| 115 |
+
href="/admin/forgot-password"
|
| 116 |
+
className="text-sm font-medium text-primary hover:underline"
|
| 117 |
+
>
|
| 118 |
+
Forgot password?
|
| 119 |
+
</Link>
|
| 120 |
+
</div>
|
| 121 |
+
{error && (
|
| 122 |
+
<div className="text-sm text-red-500 font-medium text-center">
|
| 123 |
+
{error}
|
| 124 |
+
</div>
|
| 125 |
+
)}
|
| 126 |
+
<Button type="submit" className="w-full" disabled={isLoading}>
|
| 127 |
+
{isLoading ? (
|
| 128 |
+
<>
|
| 129 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 130 |
+
Signing in...
|
| 131 |
+
</>
|
| 132 |
+
) : (
|
| 133 |
+
"Sign In"
|
| 134 |
+
)}
|
| 135 |
+
</Button>
|
| 136 |
+
</form>
|
| 137 |
+
</Form>
|
| 138 |
+
</CardContent>
|
| 139 |
+
</Card>
|
| 140 |
+
</div>
|
| 141 |
+
)
|
| 142 |
+
}
|
app/admin/page.tsx
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { redirect } from "next/navigation";
|
| 2 |
+
|
| 3 |
+
export default function AdminRoot() {
|
| 4 |
+
redirect("/admin/dashboard");
|
| 5 |
+
}
|
app/admin/portfolio/page.tsx
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback } from "react"
|
| 4 |
+
import { useSession } from "next-auth/react"
|
| 5 |
+
import { useRouter } from "next/navigation"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
| 8 |
+
import { Input } from "@/components/ui/input"
|
| 9 |
+
import { z } from "zod"
|
| 10 |
+
|
| 11 |
+
const portfolioSchema = z.object({
|
| 12 |
+
title: z.string().min(1, "Title is required"),
|
| 13 |
+
slug: z.string().min(1, "Slug is required").regex(/^[a-z0-9-]+$/, "Slug must be lowercase letters, numbers, and hyphens only"),
|
| 14 |
+
description: z.string().min(1, "Description is required"),
|
| 15 |
+
technologies: z.string().optional(),
|
| 16 |
+
imageUrl: z.string().optional(),
|
| 17 |
+
clientName: z.string().optional(),
|
| 18 |
+
industry: z.string().optional(),
|
| 19 |
+
})
|
| 20 |
+
import {
|
| 21 |
+
Dialog,
|
| 22 |
+
DialogContent,
|
| 23 |
+
DialogDescription,
|
| 24 |
+
DialogFooter,
|
| 25 |
+
DialogHeader,
|
| 26 |
+
DialogTitle,
|
| 27 |
+
} from "@/components/ui/dialog"
|
| 28 |
+
import {
|
| 29 |
+
AlertDialog,
|
| 30 |
+
AlertDialogAction,
|
| 31 |
+
AlertDialogCancel,
|
| 32 |
+
AlertDialogContent,
|
| 33 |
+
AlertDialogDescription,
|
| 34 |
+
AlertDialogFooter,
|
| 35 |
+
AlertDialogHeader,
|
| 36 |
+
AlertDialogTitle,
|
| 37 |
+
} from "@/components/ui/alert-dialog"
|
| 38 |
+
import { Plus, Pencil, Trash2, ExternalLink, Loader2 } from "lucide-react"
|
| 39 |
+
|
| 40 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000/api"
|
| 41 |
+
|
| 42 |
+
type PortfolioItem = {
|
| 43 |
+
id: string
|
| 44 |
+
title: string
|
| 45 |
+
slug: string
|
| 46 |
+
description: string
|
| 47 |
+
technologies: string | null
|
| 48 |
+
imageUrl: string | null
|
| 49 |
+
clientName: string | null
|
| 50 |
+
industry: string | null
|
| 51 |
+
testimonials: string | null
|
| 52 |
+
createdAt: string
|
| 53 |
+
updatedAt: string
|
| 54 |
+
author?: { name: string | null; email: string }
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
type FormData = {
|
| 58 |
+
title: string
|
| 59 |
+
slug: string
|
| 60 |
+
description: string
|
| 61 |
+
technologies: string
|
| 62 |
+
imageUrl: string
|
| 63 |
+
clientName: string
|
| 64 |
+
industry: string
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const emptyForm: FormData = {
|
| 68 |
+
title: "",
|
| 69 |
+
slug: "",
|
| 70 |
+
description: "",
|
| 71 |
+
technologies: "",
|
| 72 |
+
imageUrl: "",
|
| 73 |
+
clientName: "",
|
| 74 |
+
industry: "",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function slugify(str: string): string {
|
| 78 |
+
return str
|
| 79 |
+
.toLowerCase()
|
| 80 |
+
.replace(/[^a-z0-9]+/g, "-")
|
| 81 |
+
.replace(/^-|-$/g, "")
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export default function AdminPortfolioPage() {
|
| 85 |
+
const { data: session, status } = useSession()
|
| 86 |
+
const router = useRouter()
|
| 87 |
+
|
| 88 |
+
const [items, setItems] = useState<PortfolioItem[]>([])
|
| 89 |
+
const [loading, setLoading] = useState(true)
|
| 90 |
+
const [saving, setSaving] = useState(false)
|
| 91 |
+
const [formError, setFormError] = useState("")
|
| 92 |
+
|
| 93 |
+
// Dialog state
|
| 94 |
+
const [dialogOpen, setDialogOpen] = useState(false)
|
| 95 |
+
const [editingId, setEditingId] = useState<string | null>(null)
|
| 96 |
+
const [form, setForm] = useState<FormData>(emptyForm)
|
| 97 |
+
const [imageFile, setImageFile] = useState<File | null>(null)
|
| 98 |
+
|
| 99 |
+
// Delete dialog state
|
| 100 |
+
const [deleteId, setDeleteId] = useState<string | null>(null)
|
| 101 |
+
const [deleteTitle, setDeleteTitle] = useState("")
|
| 102 |
+
|
| 103 |
+
// Redirect if not authenticated
|
| 104 |
+
useEffect(() => {
|
| 105 |
+
if (status === "unauthenticated") router.push("/admin/login")
|
| 106 |
+
}, [status, router])
|
| 107 |
+
|
| 108 |
+
// Fetch portfolio items
|
| 109 |
+
const fetchItems = useCallback(async () => {
|
| 110 |
+
try {
|
| 111 |
+
const res = await fetch(`${API_URL}/admin/portfolio`, {
|
| 112 |
+
headers: { Authorization: `Bearer ${(session as any)?.accessToken || "session"}` },
|
| 113 |
+
credentials: "include",
|
| 114 |
+
})
|
| 115 |
+
if (res.ok) {
|
| 116 |
+
const data = await res.json()
|
| 117 |
+
setItems(data)
|
| 118 |
+
}
|
| 119 |
+
} catch (err) {
|
| 120 |
+
console.error("Failed to fetch portfolio items:", err)
|
| 121 |
+
} finally {
|
| 122 |
+
setLoading(false)
|
| 123 |
+
}
|
| 124 |
+
}, [session])
|
| 125 |
+
|
| 126 |
+
useEffect(() => {
|
| 127 |
+
if (status === "authenticated") fetchItems()
|
| 128 |
+
}, [status, fetchItems])
|
| 129 |
+
|
| 130 |
+
// Auto-generate slug from title
|
| 131 |
+
const handleTitleChange = (value: string) => {
|
| 132 |
+
setForm((prev) => ({
|
| 133 |
+
...prev,
|
| 134 |
+
title: value,
|
| 135 |
+
// Only auto-generate slug if creating new or slug hasn't been manually edited
|
| 136 |
+
slug: editingId ? prev.slug : slugify(value),
|
| 137 |
+
}))
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// Open create dialog
|
| 141 |
+
const openCreate = () => {
|
| 142 |
+
setEditingId(null)
|
| 143 |
+
setForm(emptyForm)
|
| 144 |
+
setImageFile(null)
|
| 145 |
+
setFormError("")
|
| 146 |
+
setDialogOpen(true)
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// Open edit dialog
|
| 150 |
+
const openEdit = (item: PortfolioItem) => {
|
| 151 |
+
setEditingId(item.id)
|
| 152 |
+
setForm({
|
| 153 |
+
title: item.title,
|
| 154 |
+
slug: item.slug,
|
| 155 |
+
description: item.description,
|
| 156 |
+
technologies: item.technologies || "",
|
| 157 |
+
imageUrl: item.imageUrl || "",
|
| 158 |
+
clientName: item.clientName || "",
|
| 159 |
+
industry: item.industry || "",
|
| 160 |
+
})
|
| 161 |
+
setImageFile(null)
|
| 162 |
+
setFormError("")
|
| 163 |
+
setDialogOpen(true)
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// Submit create/edit
|
| 167 |
+
const handleSubmit = async () => {
|
| 168 |
+
try {
|
| 169 |
+
// Validate
|
| 170 |
+
portfolioSchema.parse(form)
|
| 171 |
+
} catch (error: any) {
|
| 172 |
+
if (error instanceof z.ZodError) {
|
| 173 |
+
return setFormError(error.errors[0].message)
|
| 174 |
+
}
|
| 175 |
+
return setFormError("Invalid form data")
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
setSaving(true)
|
| 179 |
+
setFormError("")
|
| 180 |
+
|
| 181 |
+
try {
|
| 182 |
+
let uploadedImageUrl = form.imageUrl
|
| 183 |
+
|
| 184 |
+
// Upload image if selected
|
| 185 |
+
if (imageFile) {
|
| 186 |
+
const formData = new FormData()
|
| 187 |
+
formData.append("file", imageFile)
|
| 188 |
+
|
| 189 |
+
const uploadRes = await fetch(`${API_URL}/upload`, {
|
| 190 |
+
method: "POST",
|
| 191 |
+
headers: {
|
| 192 |
+
Authorization: `Bearer ${(session as any)?.accessToken || "session"}`,
|
| 193 |
+
},
|
| 194 |
+
body: formData,
|
| 195 |
+
})
|
| 196 |
+
|
| 197 |
+
if (!uploadRes.ok) {
|
| 198 |
+
const uploadData = await uploadRes.json()
|
| 199 |
+
throw new Error(uploadData.error || "Failed to upload image")
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
const uploadData = await uploadRes.json()
|
| 203 |
+
uploadedImageUrl = uploadData.url
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
const url = editingId
|
| 207 |
+
? `${API_URL}/admin/portfolio/${editingId}`
|
| 208 |
+
: `${API_URL}/admin/portfolio`
|
| 209 |
+
|
| 210 |
+
const res = await fetch(url, {
|
| 211 |
+
method: editingId ? "PATCH" : "POST",
|
| 212 |
+
headers: {
|
| 213 |
+
"Content-Type": "application/json",
|
| 214 |
+
Authorization: `Bearer ${(session as any)?.accessToken || "session"}`,
|
| 215 |
+
},
|
| 216 |
+
credentials: "include",
|
| 217 |
+
body: JSON.stringify({
|
| 218 |
+
title: form.title.trim(),
|
| 219 |
+
slug: form.slug.trim(),
|
| 220 |
+
description: form.description.trim(),
|
| 221 |
+
technologies: form.technologies.trim() || null,
|
| 222 |
+
imageUrl: uploadedImageUrl.trim() || null,
|
| 223 |
+
clientName: form.clientName.trim() || null,
|
| 224 |
+
industry: form.industry.trim() || null,
|
| 225 |
+
}),
|
| 226 |
+
})
|
| 227 |
+
|
| 228 |
+
const data = await res.json()
|
| 229 |
+
|
| 230 |
+
if (!res.ok) {
|
| 231 |
+
setFormError(data.error || "Something went wrong")
|
| 232 |
+
return
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
setDialogOpen(false)
|
| 236 |
+
fetchItems()
|
| 237 |
+
} catch (err: any) {
|
| 238 |
+
setFormError(err.message || "Network error. Please try again.")
|
| 239 |
+
} finally {
|
| 240 |
+
setSaving(false)
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
// Delete
|
| 245 |
+
const handleDelete = async () => {
|
| 246 |
+
if (!deleteId) return
|
| 247 |
+
|
| 248 |
+
try {
|
| 249 |
+
await fetch(`${API_URL}/admin/portfolio/${deleteId}`, {
|
| 250 |
+
method: "DELETE",
|
| 251 |
+
headers: { Authorization: `Bearer ${(session as any)?.accessToken || "session"}` },
|
| 252 |
+
credentials: "include",
|
| 253 |
+
})
|
| 254 |
+
setDeleteId(null)
|
| 255 |
+
fetchItems()
|
| 256 |
+
} catch (err) {
|
| 257 |
+
console.error("Delete failed:", err)
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
if (status === "loading" || loading) {
|
| 262 |
+
return (
|
| 263 |
+
<div className="flex items-center justify-center min-h-[60vh]">
|
| 264 |
+
<Loader2 className="h-8 w-8 animate-spin text-primary" suppressHydrationWarning />
|
| 265 |
+
</div>
|
| 266 |
+
)
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
return (
|
| 270 |
+
<div className="p-8 space-y-8">
|
| 271 |
+
{/* Header */}
|
| 272 |
+
<div className="flex items-center justify-between">
|
| 273 |
+
<div>
|
| 274 |
+
<h1 className="text-3xl font-bold">Portfolio</h1>
|
| 275 |
+
<p className="text-muted-foreground mt-1">Manage your portfolio projects</p>
|
| 276 |
+
</div>
|
| 277 |
+
<Button onClick={openCreate}>
|
| 278 |
+
<Plus className="mr-2 h-4 w-4" />
|
| 279 |
+
New Project
|
| 280 |
+
</Button>
|
| 281 |
+
</div>
|
| 282 |
+
|
| 283 |
+
{/* Portfolio items list */}
|
| 284 |
+
{items.length === 0 ? (
|
| 285 |
+
<Card>
|
| 286 |
+
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
| 287 |
+
<p className="text-muted-foreground mb-4">No portfolio items yet.</p>
|
| 288 |
+
<Button onClick={openCreate} variant="outline">
|
| 289 |
+
<Plus className="mr-2 h-4 w-4" />
|
| 290 |
+
Create Your First Project
|
| 291 |
+
</Button>
|
| 292 |
+
</CardContent>
|
| 293 |
+
</Card>
|
| 294 |
+
) : (
|
| 295 |
+
<div className="grid gap-4">
|
| 296 |
+
{items.map((item) => (
|
| 297 |
+
<Card key={item.id} className="hover:shadow-md transition-shadow">
|
| 298 |
+
<CardContent className="flex items-center justify-between py-4">
|
| 299 |
+
<div className="flex-1 min-w-0">
|
| 300 |
+
<div className="flex items-center gap-3">
|
| 301 |
+
<h3 className="font-semibold truncate">{item.title}</h3>
|
| 302 |
+
{item.industry && (
|
| 303 |
+
<span className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full text-xs">
|
| 304 |
+
{item.industry}
|
| 305 |
+
</span>
|
| 306 |
+
)}
|
| 307 |
+
</div>
|
| 308 |
+
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
|
| 309 |
+
{item.description}
|
| 310 |
+
</p>
|
| 311 |
+
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
| 312 |
+
<span className="font-mono">/{item.slug}</span>
|
| 313 |
+
{item.technologies && (
|
| 314 |
+
<span>{item.technologies}</span>
|
| 315 |
+
)}
|
| 316 |
+
<span>{new Date(item.createdAt).toLocaleDateString()}</span>
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
<div className="flex items-center gap-2 ml-4">
|
| 320 |
+
<Button variant="ghost" size="icon-sm" asChild>
|
| 321 |
+
<a href={`/portfolio/${item.slug}`} target="_blank" rel="noopener noreferrer">
|
| 322 |
+
<ExternalLink className="h-4 w-4" />
|
| 323 |
+
</a>
|
| 324 |
+
</Button>
|
| 325 |
+
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(item)}>
|
| 326 |
+
<Pencil className="h-4 w-4" />
|
| 327 |
+
</Button>
|
| 328 |
+
<Button
|
| 329 |
+
variant="ghost"
|
| 330 |
+
size="icon-sm"
|
| 331 |
+
className="text-destructive hover:text-destructive"
|
| 332 |
+
onClick={() => { setDeleteId(item.id); setDeleteTitle(item.title) }}
|
| 333 |
+
>
|
| 334 |
+
<Trash2 className="h-4 w-4" />
|
| 335 |
+
</Button>
|
| 336 |
+
</div>
|
| 337 |
+
</CardContent>
|
| 338 |
+
</Card>
|
| 339 |
+
))}
|
| 340 |
+
</div>
|
| 341 |
+
)}
|
| 342 |
+
|
| 343 |
+
{/* Create/Edit Dialog */}
|
| 344 |
+
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
| 345 |
+
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
| 346 |
+
<DialogHeader>
|
| 347 |
+
<DialogTitle>{editingId ? "Edit Project" : "New Project"}</DialogTitle>
|
| 348 |
+
<DialogDescription>
|
| 349 |
+
{editingId ? "Update the project details below." : "Fill in the details for your new portfolio project."}
|
| 350 |
+
</DialogDescription>
|
| 351 |
+
</DialogHeader>
|
| 352 |
+
<div className="space-y-4 py-4">
|
| 353 |
+
<div>
|
| 354 |
+
<label className="text-sm font-medium mb-1 block">Title *</label>
|
| 355 |
+
<Input
|
| 356 |
+
value={form.title}
|
| 357 |
+
onChange={(e) => handleTitleChange(e.target.value)}
|
| 358 |
+
placeholder="My Awesome Project"
|
| 359 |
+
/>
|
| 360 |
+
</div>
|
| 361 |
+
<div>
|
| 362 |
+
<label className="text-sm font-medium mb-1 block">Slug *</label>
|
| 363 |
+
<Input
|
| 364 |
+
value={form.slug}
|
| 365 |
+
onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))}
|
| 366 |
+
placeholder="my-awesome-project"
|
| 367 |
+
className="font-mono text-sm"
|
| 368 |
+
/>
|
| 369 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 370 |
+
URL: /portfolio/{form.slug || "..."}
|
| 371 |
+
</p>
|
| 372 |
+
</div>
|
| 373 |
+
<div>
|
| 374 |
+
<label className="text-sm font-medium mb-1 block">Description *</label>
|
| 375 |
+
<textarea
|
| 376 |
+
className="flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
| 377 |
+
value={form.description}
|
| 378 |
+
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
|
| 379 |
+
placeholder="A brief description of the project..."
|
| 380 |
+
/>
|
| 381 |
+
</div>
|
| 382 |
+
<div>
|
| 383 |
+
<label className="text-sm font-medium mb-1 block">Technologies</label>
|
| 384 |
+
<Input
|
| 385 |
+
value={form.technologies}
|
| 386 |
+
onChange={(e) => setForm((p) => ({ ...p, technologies: e.target.value }))}
|
| 387 |
+
placeholder="Next.js, React, Tailwind CSS"
|
| 388 |
+
/>
|
| 389 |
+
<p className="text-xs text-muted-foreground mt-1">Comma-separated list</p>
|
| 390 |
+
</div>
|
| 391 |
+
<div className="grid grid-cols-2 gap-4">
|
| 392 |
+
<div>
|
| 393 |
+
<label className="text-sm font-medium mb-1 block">Client Name</label>
|
| 394 |
+
<Input
|
| 395 |
+
value={form.clientName}
|
| 396 |
+
onChange={(e) => setForm((p) => ({ ...p, clientName: e.target.value }))}
|
| 397 |
+
placeholder="Acme Corp"
|
| 398 |
+
/>
|
| 399 |
+
</div>
|
| 400 |
+
<div>
|
| 401 |
+
<label className="text-sm font-medium mb-1 block">Industry</label>
|
| 402 |
+
<Input
|
| 403 |
+
value={form.industry}
|
| 404 |
+
onChange={(e) => setForm((p) => ({ ...p, industry: e.target.value }))}
|
| 405 |
+
placeholder="E-commerce"
|
| 406 |
+
/>
|
| 407 |
+
</div>
|
| 408 |
+
</div>
|
| 409 |
+
<div>
|
| 410 |
+
<label className="text-sm font-medium mb-1 block">Image</label>
|
| 411 |
+
<div className="flex gap-4 items-start">
|
| 412 |
+
<div className="flex-1 space-y-2">
|
| 413 |
+
<Input
|
| 414 |
+
type="file"
|
| 415 |
+
accept="image/*"
|
| 416 |
+
onChange={(e) => {
|
| 417 |
+
if (e.target.files && e.target.files[0]) {
|
| 418 |
+
setImageFile(e.target.files[0])
|
| 419 |
+
}
|
| 420 |
+
}}
|
| 421 |
+
/>
|
| 422 |
+
<p className="text-xs text-muted-foreground">Or provide an image URL directly:</p>
|
| 423 |
+
<Input
|
| 424 |
+
value={form.imageUrl}
|
| 425 |
+
onChange={(e) => { setForm((p) => ({ ...p, imageUrl: e.target.value })); setImageFile(null) }}
|
| 426 |
+
placeholder="https://example.com/image.png"
|
| 427 |
+
/>
|
| 428 |
+
</div>
|
| 429 |
+
{(imageFile || form.imageUrl) && (
|
| 430 |
+
<div className="w-24 h-24 rounded-md border shrink-0 overflow-hidden bg-muted">
|
| 431 |
+
<img
|
| 432 |
+
src={imageFile ? URL.createObjectURL(imageFile) : (form.imageUrl.startsWith("http") || form.imageUrl.startsWith("/")) ? form.imageUrl : `/${form.imageUrl}`}
|
| 433 |
+
alt="Preview"
|
| 434 |
+
className="w-full h-full object-cover"
|
| 435 |
+
/>
|
| 436 |
+
</div>
|
| 437 |
+
)}
|
| 438 |
+
</div>
|
| 439 |
+
</div>
|
| 440 |
+
{formError && (
|
| 441 |
+
<p className="text-sm text-destructive font-medium">{formError}</p>
|
| 442 |
+
)}
|
| 443 |
+
</div>
|
| 444 |
+
<DialogFooter>
|
| 445 |
+
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
|
| 446 |
+
Cancel
|
| 447 |
+
</Button>
|
| 448 |
+
<Button onClick={handleSubmit} disabled={saving}>
|
| 449 |
+
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
| 450 |
+
{editingId ? "Save Changes" : "Create Project"}
|
| 451 |
+
</Button>
|
| 452 |
+
</DialogFooter>
|
| 453 |
+
</DialogContent>
|
| 454 |
+
</Dialog>
|
| 455 |
+
|
| 456 |
+
{/* Delete Confirmation Dialog */}
|
| 457 |
+
<AlertDialog open={!!deleteId} onOpenChange={(open) => { if (!open) setDeleteId(null) }}>
|
| 458 |
+
<AlertDialogContent>
|
| 459 |
+
<AlertDialogHeader>
|
| 460 |
+
<AlertDialogTitle>Delete "{deleteTitle}"?</AlertDialogTitle>
|
| 461 |
+
<AlertDialogDescription>
|
| 462 |
+
This action cannot be undone. This will permanently delete the portfolio item and remove it from the website.
|
| 463 |
+
</AlertDialogDescription>
|
| 464 |
+
</AlertDialogHeader>
|
| 465 |
+
<AlertDialogFooter>
|
| 466 |
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
| 467 |
+
<AlertDialogAction
|
| 468 |
+
onClick={handleDelete}
|
| 469 |
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
| 470 |
+
>
|
| 471 |
+
Delete
|
| 472 |
+
</AlertDialogAction>
|
| 473 |
+
</AlertDialogFooter>
|
| 474 |
+
</AlertDialogContent>
|
| 475 |
+
</AlertDialog>
|
| 476 |
+
</div>
|
| 477 |
+
)
|
| 478 |
+
}
|
app/admin/posts/page.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { RoleGuard } from "@/components/auth/RoleGuard"
|
| 4 |
+
import { Role } from "@/types/auth"
|
| 5 |
+
|
| 6 |
+
export default function PostsPage() {
|
| 7 |
+
return (
|
| 8 |
+
<RoleGuard allowedRoles={[Role.SUPER_ADMIN, Role.EDITOR]}>
|
| 9 |
+
<div className="p-8">
|
| 10 |
+
<h1 className="text-3xl font-bold mb-4">Content Management</h1>
|
| 11 |
+
<div className="border p-4 rounded bg-background">
|
| 12 |
+
<p>Super Admins and Editors can manage content here.</p>
|
| 13 |
+
{/* Blog/Portfolio management UI would go here */}
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
</RoleGuard>
|
| 17 |
+
)
|
| 18 |
+
}
|
app/admin/reset-password/page.tsx
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, Suspense } from "react"
|
| 4 |
+
import { useRouter, useSearchParams } from "next/navigation"
|
| 5 |
+
import { Button } from "@/components/ui/button"
|
| 6 |
+
import { Input } from "@/components/ui/input"
|
| 7 |
+
import { Label } from "@/components/ui/label"
|
| 8 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
| 9 |
+
|
| 10 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api';
|
| 11 |
+
|
| 12 |
+
function ResetPasswordForm() {
|
| 13 |
+
const router = useRouter()
|
| 14 |
+
const searchParams = useSearchParams()
|
| 15 |
+
const token = searchParams.get("token")
|
| 16 |
+
|
| 17 |
+
const [password, setPassword] = useState("")
|
| 18 |
+
const [confirmPassword, setConfirmPassword] = useState("")
|
| 19 |
+
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
|
| 20 |
+
const [errorMessage, setErrorMessage] = useState("")
|
| 21 |
+
|
| 22 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 23 |
+
e.preventDefault()
|
| 24 |
+
|
| 25 |
+
if (password !== confirmPassword) {
|
| 26 |
+
setErrorMessage("Passwords do not match")
|
| 27 |
+
return
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
if (!token) {
|
| 31 |
+
setErrorMessage("Missing reset token")
|
| 32 |
+
return
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
setStatus("loading")
|
| 36 |
+
setErrorMessage("")
|
| 37 |
+
|
| 38 |
+
try {
|
| 39 |
+
const res = await fetch(`${API_URL}/auth/reset-password`, {
|
| 40 |
+
method: "POST",
|
| 41 |
+
headers: { "Content-Type": "application/json" },
|
| 42 |
+
body: JSON.stringify({ token, password }),
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
const data = await res.json()
|
| 46 |
+
|
| 47 |
+
if (res.ok) {
|
| 48 |
+
setStatus("success")
|
| 49 |
+
setTimeout(() => {
|
| 50 |
+
router.push("/admin/login")
|
| 51 |
+
}, 2000)
|
| 52 |
+
} else {
|
| 53 |
+
setStatus("error")
|
| 54 |
+
setErrorMessage(data.error || "Failed to reset password")
|
| 55 |
+
}
|
| 56 |
+
} catch (error) {
|
| 57 |
+
setStatus("error")
|
| 58 |
+
setErrorMessage("An error occurred")
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
if (!token) {
|
| 63 |
+
return (
|
| 64 |
+
<div className="text-destructive">
|
| 65 |
+
Invalid link. Missing token.
|
| 66 |
+
</div>
|
| 67 |
+
)
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 72 |
+
{status === "error" && (
|
| 73 |
+
<div className="bg-destructive/15 text-destructive p-3 rounded-md text-sm">
|
| 74 |
+
{errorMessage}
|
| 75 |
+
</div>
|
| 76 |
+
)}
|
| 77 |
+
{status === "success" && (
|
| 78 |
+
<div className="bg-green-100 text-green-700 p-3 rounded-md text-sm">
|
| 79 |
+
Password updated! Redirecting to login...
|
| 80 |
+
</div>
|
| 81 |
+
)}
|
| 82 |
+
|
| 83 |
+
<div className="space-y-2">
|
| 84 |
+
<Label htmlFor="password">New Password</Label>
|
| 85 |
+
<Input
|
| 86 |
+
id="password"
|
| 87 |
+
type="password"
|
| 88 |
+
required
|
| 89 |
+
value={password}
|
| 90 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 91 |
+
disabled={status === "loading" || status === "success"}
|
| 92 |
+
/>
|
| 93 |
+
</div>
|
| 94 |
+
<div className="space-y-2">
|
| 95 |
+
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
| 96 |
+
<Input
|
| 97 |
+
id="confirmPassword"
|
| 98 |
+
type="password"
|
| 99 |
+
required
|
| 100 |
+
value={confirmPassword}
|
| 101 |
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
| 102 |
+
disabled={status === "loading" || status === "success"}
|
| 103 |
+
/>
|
| 104 |
+
</div>
|
| 105 |
+
<Button type="submit" className="w-full" disabled={status === "loading" || status === "success"}>
|
| 106 |
+
{status === "loading" ? "Resetting..." : "Reset Password"}
|
| 107 |
+
</Button>
|
| 108 |
+
</form>
|
| 109 |
+
)
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export default function ResetPasswordPage() {
|
| 113 |
+
return (
|
| 114 |
+
<div className="flex min-h-screen items-center justify-center bg-muted/50 p-4">
|
| 115 |
+
<Card className="w-full max-w-md">
|
| 116 |
+
<CardHeader>
|
| 117 |
+
<CardTitle className="text-2xl font-bold">Reset Password</CardTitle>
|
| 118 |
+
<CardDescription>
|
| 119 |
+
Enter your new password below.
|
| 120 |
+
</CardDescription>
|
| 121 |
+
</CardHeader>
|
| 122 |
+
<CardContent>
|
| 123 |
+
<Suspense fallback={<div>Loading...</div>}>
|
| 124 |
+
<ResetPasswordForm />
|
| 125 |
+
</Suspense>
|
| 126 |
+
</CardContent>
|
| 127 |
+
</Card>
|
| 128 |
+
</div>
|
| 129 |
+
)
|
| 130 |
+
}
|
app/admin/settings/page.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { RoleGuard } from "@/components/auth/RoleGuard"
|
| 4 |
+
import { Role } from "@/types/auth"
|
| 5 |
+
|
| 6 |
+
export default function SettingsPage() {
|
| 7 |
+
return (
|
| 8 |
+
<RoleGuard allowedRoles={[Role.SUPER_ADMIN]}>
|
| 9 |
+
<div className="p-8">
|
| 10 |
+
<h1 className="text-3xl font-bold mb-4">System Settings</h1>
|
| 11 |
+
<div className="border p-4 rounded bg-background">
|
| 12 |
+
<div className="bg-green-100 text-green-700 p-4 rounded mb-4">
|
| 13 |
+
<strong>Access Granted:</strong> You are viewing this page because you have the <strong>SUPER_ADMIN</strong> role.
|
| 14 |
+
</div>
|
| 15 |
+
<p>Settings configuration panel will be implemented here.</p>
|
| 16 |
+
</div>
|
| 17 |
+
</div>
|
| 18 |
+
</RoleGuard>
|
| 19 |
+
)
|
| 20 |
+
}
|
app/admin/testimonials/page.tsx
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback } from "react"
|
| 4 |
+
import { useSession } from "next-auth/react"
|
| 5 |
+
import { useRouter } from "next/navigation"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 8 |
+
import { Input } from "@/components/ui/input"
|
| 9 |
+
import {
|
| 10 |
+
Dialog,
|
| 11 |
+
DialogContent,
|
| 12 |
+
DialogDescription,
|
| 13 |
+
DialogFooter,
|
| 14 |
+
DialogHeader,
|
| 15 |
+
DialogTitle,
|
| 16 |
+
} from "@/components/ui/dialog"
|
| 17 |
+
import {
|
| 18 |
+
AlertDialog,
|
| 19 |
+
AlertDialogAction,
|
| 20 |
+
AlertDialogCancel,
|
| 21 |
+
AlertDialogContent,
|
| 22 |
+
AlertDialogDescription,
|
| 23 |
+
AlertDialogFooter,
|
| 24 |
+
AlertDialogHeader,
|
| 25 |
+
AlertDialogTitle,
|
| 26 |
+
} from "@/components/ui/alert-dialog"
|
| 27 |
+
import { Plus, Pencil, Trash2, Star, CheckCircle, XCircle, Loader2 } from "lucide-react"
|
| 28 |
+
|
| 29 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000/api"
|
| 30 |
+
|
| 31 |
+
type Testimonial = {
|
| 32 |
+
id: string
|
| 33 |
+
clientName: string
|
| 34 |
+
company: string | null
|
| 35 |
+
content: string
|
| 36 |
+
rating: number
|
| 37 |
+
imageUrl: string | null
|
| 38 |
+
approved: boolean
|
| 39 |
+
createdAt: string
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
type FormData = {
|
| 43 |
+
clientName: string
|
| 44 |
+
company: string
|
| 45 |
+
content: string
|
| 46 |
+
rating: number
|
| 47 |
+
imageUrl: string
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const emptyForm: FormData = {
|
| 51 |
+
clientName: "",
|
| 52 |
+
company: "",
|
| 53 |
+
content: "",
|
| 54 |
+
rating: 5,
|
| 55 |
+
imageUrl: "",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export default function AdminTestimonialsPage() {
|
| 59 |
+
const { data: session, status } = useSession()
|
| 60 |
+
const router = useRouter()
|
| 61 |
+
|
| 62 |
+
const [items, setItems] = useState<Testimonial[]>([])
|
| 63 |
+
const [loading, setLoading] = useState(true)
|
| 64 |
+
const [saving, setSaving] = useState(false)
|
| 65 |
+
const [formError, setFormError] = useState("")
|
| 66 |
+
|
| 67 |
+
const [dialogOpen, setDialogOpen] = useState(false)
|
| 68 |
+
const [editingId, setEditingId] = useState<string | null>(null)
|
| 69 |
+
const [form, setForm] = useState<FormData>(emptyForm)
|
| 70 |
+
|
| 71 |
+
const [deleteId, setDeleteId] = useState<string | null>(null)
|
| 72 |
+
const [deleteName, setDeleteName] = useState("")
|
| 73 |
+
|
| 74 |
+
useEffect(() => {
|
| 75 |
+
if (status === "unauthenticated") router.push("/admin/login")
|
| 76 |
+
}, [status, router])
|
| 77 |
+
|
| 78 |
+
const fetchItems = useCallback(async () => {
|
| 79 |
+
try {
|
| 80 |
+
const res = await fetch(`${API_URL}/admin/testimonials`, {
|
| 81 |
+
headers: { Authorization: `Bearer ${(session as any)?.accessToken || "session"}` },
|
| 82 |
+
credentials: "include",
|
| 83 |
+
})
|
| 84 |
+
if (res.ok) {
|
| 85 |
+
const data = await res.json()
|
| 86 |
+
setItems(data)
|
| 87 |
+
}
|
| 88 |
+
} catch (err) {
|
| 89 |
+
console.error("Failed to fetch testimonials:", err)
|
| 90 |
+
} finally {
|
| 91 |
+
setLoading(false)
|
| 92 |
+
}
|
| 93 |
+
}, [session])
|
| 94 |
+
|
| 95 |
+
useEffect(() => {
|
| 96 |
+
if (status === "authenticated") fetchItems()
|
| 97 |
+
}, [status, fetchItems])
|
| 98 |
+
|
| 99 |
+
const openCreate = () => {
|
| 100 |
+
setEditingId(null)
|
| 101 |
+
setForm(emptyForm)
|
| 102 |
+
setFormError("")
|
| 103 |
+
setDialogOpen(true)
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
const openEdit = (item: Testimonial) => {
|
| 107 |
+
setEditingId(item.id)
|
| 108 |
+
setForm({
|
| 109 |
+
clientName: item.clientName,
|
| 110 |
+
company: item.company || "",
|
| 111 |
+
content: item.content,
|
| 112 |
+
rating: item.rating,
|
| 113 |
+
imageUrl: item.imageUrl || "",
|
| 114 |
+
})
|
| 115 |
+
setFormError("")
|
| 116 |
+
setDialogOpen(true)
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const handleSubmit = async () => {
|
| 120 |
+
if (!form.clientName.trim()) return setFormError("Client name is required")
|
| 121 |
+
if (!form.content.trim()) return setFormError("Testimonial content is required")
|
| 122 |
+
if (form.rating < 1 || form.rating > 5) return setFormError("Rating must be between 1 and 5")
|
| 123 |
+
|
| 124 |
+
setSaving(true)
|
| 125 |
+
setFormError("")
|
| 126 |
+
|
| 127 |
+
try {
|
| 128 |
+
const url = editingId
|
| 129 |
+
? `${API_URL}/admin/testimonials/${editingId}`
|
| 130 |
+
: `${API_URL}/admin/testimonials`
|
| 131 |
+
|
| 132 |
+
const res = await fetch(url, {
|
| 133 |
+
method: editingId ? "PATCH" : "POST",
|
| 134 |
+
headers: {
|
| 135 |
+
"Content-Type": "application/json",
|
| 136 |
+
Authorization: `Bearer ${(session as any)?.accessToken || "session"}`,
|
| 137 |
+
},
|
| 138 |
+
credentials: "include",
|
| 139 |
+
body: JSON.stringify({
|
| 140 |
+
clientName: form.clientName.trim(),
|
| 141 |
+
company: form.company.trim() || null,
|
| 142 |
+
content: form.content.trim(),
|
| 143 |
+
rating: form.rating,
|
| 144 |
+
imageUrl: form.imageUrl.trim() || null,
|
| 145 |
+
}),
|
| 146 |
+
})
|
| 147 |
+
|
| 148 |
+
const data = await res.json()
|
| 149 |
+
if (!res.ok) {
|
| 150 |
+
setFormError(data.error || "Something went wrong")
|
| 151 |
+
return
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
setDialogOpen(false)
|
| 155 |
+
fetchItems()
|
| 156 |
+
} catch (err) {
|
| 157 |
+
setFormError("Network error. Please try again.")
|
| 158 |
+
} finally {
|
| 159 |
+
setSaving(false)
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
const handleToggle = async (id: string) => {
|
| 164 |
+
try {
|
| 165 |
+
await fetch(`${API_URL}/admin/testimonials/${id}/toggle`, {
|
| 166 |
+
method: "PATCH",
|
| 167 |
+
headers: { Authorization: `Bearer ${(session as any)?.accessToken || "session"}` },
|
| 168 |
+
credentials: "include",
|
| 169 |
+
})
|
| 170 |
+
fetchItems()
|
| 171 |
+
} catch (err) {
|
| 172 |
+
console.error("Toggle failed:", err)
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const handleDelete = async () => {
|
| 177 |
+
if (!deleteId) return
|
| 178 |
+
try {
|
| 179 |
+
await fetch(`${API_URL}/admin/testimonials/${deleteId}`, {
|
| 180 |
+
method: "DELETE",
|
| 181 |
+
headers: { Authorization: `Bearer ${(session as any)?.accessToken || "session"}` },
|
| 182 |
+
credentials: "include",
|
| 183 |
+
})
|
| 184 |
+
setDeleteId(null)
|
| 185 |
+
fetchItems()
|
| 186 |
+
} catch (err) {
|
| 187 |
+
console.error("Delete failed:", err)
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
if (status === "loading" || loading) {
|
| 192 |
+
return (
|
| 193 |
+
<div className="flex items-center justify-center min-h-[60vh]">
|
| 194 |
+
<Loader2 className="h-8 w-8 animate-spin text-primary" suppressHydrationWarning />
|
| 195 |
+
</div>
|
| 196 |
+
)
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
return (
|
| 200 |
+
<div className="p-8 space-y-8">
|
| 201 |
+
{/* Header */}
|
| 202 |
+
<div className="flex items-center justify-between">
|
| 203 |
+
<div>
|
| 204 |
+
<h1 className="text-3xl font-bold">Testimonials</h1>
|
| 205 |
+
<p className="text-muted-foreground mt-1">Manage client testimonials and approval status</p>
|
| 206 |
+
</div>
|
| 207 |
+
<Button onClick={openCreate}>
|
| 208 |
+
<Plus className="mr-2 h-4 w-4" suppressHydrationWarning />
|
| 209 |
+
New Testimonial
|
| 210 |
+
</Button>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
{/* Testimonials list */}
|
| 214 |
+
{items.length === 0 ? (
|
| 215 |
+
<Card>
|
| 216 |
+
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
| 217 |
+
<p className="text-muted-foreground mb-4">No testimonials yet.</p>
|
| 218 |
+
<Button onClick={openCreate} variant="outline">
|
| 219 |
+
<Plus className="mr-2 h-4 w-4" suppressHydrationWarning />
|
| 220 |
+
Add Your First Testimonial
|
| 221 |
+
</Button>
|
| 222 |
+
</CardContent>
|
| 223 |
+
</Card>
|
| 224 |
+
) : (
|
| 225 |
+
<div className="grid gap-4">
|
| 226 |
+
{items.map((item) => (
|
| 227 |
+
<Card key={item.id} className="hover:shadow-md transition-shadow">
|
| 228 |
+
<CardContent className="flex items-start justify-between py-4 gap-4">
|
| 229 |
+
<div className="flex-1 min-w-0">
|
| 230 |
+
<div className="flex items-center gap-3 mb-1">
|
| 231 |
+
<h3 className="font-semibold">{item.clientName}</h3>
|
| 232 |
+
{item.company && (
|
| 233 |
+
<span className="text-sm text-muted-foreground">
|
| 234 |
+
{item.company}
|
| 235 |
+
</span>
|
| 236 |
+
)}
|
| 237 |
+
{/* Approval badge */}
|
| 238 |
+
{item.approved ? (
|
| 239 |
+
<span className="inline-flex items-center gap-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 px-2 py-0.5 rounded-full text-xs font-medium">
|
| 240 |
+
<CheckCircle className="h-3 w-3" suppressHydrationWarning />
|
| 241 |
+
Approved
|
| 242 |
+
</span>
|
| 243 |
+
) : (
|
| 244 |
+
<span className="inline-flex items-center gap-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 px-2 py-0.5 rounded-full text-xs font-medium">
|
| 245 |
+
<XCircle className="h-3 w-3" suppressHydrationWarning />
|
| 246 |
+
Pending
|
| 247 |
+
</span>
|
| 248 |
+
)}
|
| 249 |
+
</div>
|
| 250 |
+
<p className="text-sm text-muted-foreground line-clamp-2 mb-2">
|
| 251 |
+
“{item.content}”
|
| 252 |
+
</p>
|
| 253 |
+
<div className="flex items-center gap-1">
|
| 254 |
+
{[...Array(5)].map((_, j) => (
|
| 255 |
+
<Star
|
| 256 |
+
key={j}
|
| 257 |
+
className={`h-3.5 w-3.5 ${j < item.rating ? "fill-amber-400 text-amber-400" : "text-muted-foreground/30"}`}
|
| 258 |
+
suppressHydrationWarning
|
| 259 |
+
/>
|
| 260 |
+
))}
|
| 261 |
+
<span className="text-xs text-muted-foreground ml-2">
|
| 262 |
+
{new Date(item.createdAt).toLocaleDateString()}
|
| 263 |
+
</span>
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
<div className="flex items-center gap-1 shrink-0">
|
| 267 |
+
<Button
|
| 268 |
+
variant="ghost"
|
| 269 |
+
size="sm"
|
| 270 |
+
onClick={() => handleToggle(item.id)}
|
| 271 |
+
className={item.approved ? "text-amber-600 hover:text-amber-700" : "text-green-600 hover:text-green-700"}
|
| 272 |
+
>
|
| 273 |
+
{item.approved ? "Unpublish" : "Approve"}
|
| 274 |
+
</Button>
|
| 275 |
+
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(item)}>
|
| 276 |
+
<Pencil className="h-4 w-4" suppressHydrationWarning />
|
| 277 |
+
</Button>
|
| 278 |
+
<Button
|
| 279 |
+
variant="ghost"
|
| 280 |
+
size="icon-sm"
|
| 281 |
+
className="text-destructive hover:text-destructive"
|
| 282 |
+
onClick={() => { setDeleteId(item.id); setDeleteName(item.clientName) }}
|
| 283 |
+
>
|
| 284 |
+
<Trash2 className="h-4 w-4" suppressHydrationWarning />
|
| 285 |
+
</Button>
|
| 286 |
+
</div>
|
| 287 |
+
</CardContent>
|
| 288 |
+
</Card>
|
| 289 |
+
))}
|
| 290 |
+
</div>
|
| 291 |
+
)}
|
| 292 |
+
|
| 293 |
+
{/* Create/Edit Dialog */}
|
| 294 |
+
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
| 295 |
+
<DialogContent className="max-w-lg">
|
| 296 |
+
<DialogHeader>
|
| 297 |
+
<DialogTitle>{editingId ? "Edit Testimonial" : "New Testimonial"}</DialogTitle>
|
| 298 |
+
<DialogDescription>
|
| 299 |
+
{editingId ? "Update the testimonial details." : "Add a new client testimonial."}
|
| 300 |
+
</DialogDescription>
|
| 301 |
+
</DialogHeader>
|
| 302 |
+
<div className="space-y-4 py-4">
|
| 303 |
+
<div className="grid grid-cols-2 gap-4">
|
| 304 |
+
<div>
|
| 305 |
+
<label className="text-sm font-medium mb-1 block">Client Name *</label>
|
| 306 |
+
<Input
|
| 307 |
+
value={form.clientName}
|
| 308 |
+
onChange={(e) => setForm((p) => ({ ...p, clientName: e.target.value }))}
|
| 309 |
+
placeholder="Jane Doe"
|
| 310 |
+
/>
|
| 311 |
+
</div>
|
| 312 |
+
<div>
|
| 313 |
+
<label className="text-sm font-medium mb-1 block">Company</label>
|
| 314 |
+
<Input
|
| 315 |
+
value={form.company}
|
| 316 |
+
onChange={(e) => setForm((p) => ({ ...p, company: e.target.value }))}
|
| 317 |
+
placeholder="Acme Inc."
|
| 318 |
+
/>
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
+
<div>
|
| 322 |
+
<label className="text-sm font-medium mb-1 block">Testimonial *</label>
|
| 323 |
+
<textarea
|
| 324 |
+
className="flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
| 325 |
+
value={form.content}
|
| 326 |
+
onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))}
|
| 327 |
+
placeholder="What the client said about your work..."
|
| 328 |
+
/>
|
| 329 |
+
</div>
|
| 330 |
+
<div>
|
| 331 |
+
<label className="text-sm font-medium mb-1 block">Rating (1-5)</label>
|
| 332 |
+
<div className="flex items-center gap-1">
|
| 333 |
+
{[1, 2, 3, 4, 5].map((val) => (
|
| 334 |
+
<button
|
| 335 |
+
key={val}
|
| 336 |
+
type="button"
|
| 337 |
+
onClick={() => setForm((p) => ({ ...p, rating: val }))}
|
| 338 |
+
className="p-1 hover:scale-110 transition-transform"
|
| 339 |
+
>
|
| 340 |
+
<Star
|
| 341 |
+
className={`h-6 w-6 ${val <= form.rating ? "fill-amber-400 text-amber-400" : "text-muted-foreground/30"}`}
|
| 342 |
+
suppressHydrationWarning
|
| 343 |
+
/>
|
| 344 |
+
</button>
|
| 345 |
+
))}
|
| 346 |
+
<span className="text-sm text-muted-foreground ml-2">{form.rating}/5</span>
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
+
<div>
|
| 350 |
+
<label className="text-sm font-medium mb-1 block">Avatar URL</label>
|
| 351 |
+
<Input
|
| 352 |
+
value={form.imageUrl}
|
| 353 |
+
onChange={(e) => setForm((p) => ({ ...p, imageUrl: e.target.value }))}
|
| 354 |
+
placeholder="https://example.com/avatar.jpg"
|
| 355 |
+
/>
|
| 356 |
+
</div>
|
| 357 |
+
{formError && (
|
| 358 |
+
<p className="text-sm text-destructive font-medium">{formError}</p>
|
| 359 |
+
)}
|
| 360 |
+
</div>
|
| 361 |
+
<DialogFooter>
|
| 362 |
+
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
|
| 363 |
+
Cancel
|
| 364 |
+
</Button>
|
| 365 |
+
<Button onClick={handleSubmit} disabled={saving}>
|
| 366 |
+
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" suppressHydrationWarning />}
|
| 367 |
+
{editingId ? "Save Changes" : "Add Testimonial"}
|
| 368 |
+
</Button>
|
| 369 |
+
</DialogFooter>
|
| 370 |
+
</DialogContent>
|
| 371 |
+
</Dialog>
|
| 372 |
+
|
| 373 |
+
{/* Delete Confirmation */}
|
| 374 |
+
<AlertDialog open={!!deleteId} onOpenChange={(open) => { if (!open) setDeleteId(null) }}>
|
| 375 |
+
<AlertDialogContent>
|
| 376 |
+
<AlertDialogHeader>
|
| 377 |
+
<AlertDialogTitle>Delete testimonial from "{deleteName}"?</AlertDialogTitle>
|
| 378 |
+
<AlertDialogDescription>
|
| 379 |
+
This action cannot be undone. This will permanently remove this testimonial.
|
| 380 |
+
</AlertDialogDescription>
|
| 381 |
+
</AlertDialogHeader>
|
| 382 |
+
<AlertDialogFooter>
|
| 383 |
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
| 384 |
+
<AlertDialogAction
|
| 385 |
+
onClick={handleDelete}
|
| 386 |
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
| 387 |
+
>
|
| 388 |
+
Delete
|
| 389 |
+
</AlertDialogAction>
|
| 390 |
+
</AlertDialogFooter>
|
| 391 |
+
</AlertDialogContent>
|
| 392 |
+
</AlertDialog>
|
| 393 |
+
</div>
|
| 394 |
+
)
|
| 395 |
+
}
|
app/api/auth/[...nextauth]/route.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { handlers } from "@/lib/auth"
|
| 2 |
+
export const { GET, POST } = handlers
|
app/favicon.ico
ADDED
|
|
app/globals.css
ADDED
|
@@ -0,0 +1,1494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
@plugin "tailwindcss-animate";
|
| 4 |
+
@plugin "@tailwindcss/typography";
|
| 5 |
+
|
| 6 |
+
@custom-variant dark (&:is(.dark *));
|
| 7 |
+
|
| 8 |
+
/* ───────────────────────────────────────────────
|
| 9 |
+
NexaFlow Brand Palette
|
| 10 |
+
─────────────────────────────────────────────── */
|
| 11 |
+
|
| 12 |
+
:root {
|
| 13 |
+
/* Rupam Portfolio Style - Neumorphic Light */
|
| 14 |
+
--background: 224 14% 90%;
|
| 15 |
+
--foreground: 215 25% 27%;
|
| 16 |
+
--card: 224 14% 90%;
|
| 17 |
+
--card-foreground: 215 25% 27%;
|
| 18 |
+
--popover: 224 14% 90%;
|
| 19 |
+
--popover-foreground: 215 25% 27%;
|
| 20 |
+
|
| 21 |
+
--primary: 248 73% 65%;
|
| 22 |
+
--primary-foreground: 0 0% 100%;
|
| 23 |
+
|
| 24 |
+
--secondary: 220 14% 96%;
|
| 25 |
+
--secondary-foreground: 215 25% 27%;
|
| 26 |
+
|
| 27 |
+
--muted: 220 14% 96%;
|
| 28 |
+
--muted-foreground: 215 16% 47%;
|
| 29 |
+
|
| 30 |
+
--accent: 248 73% 65%;
|
| 31 |
+
--accent-foreground: 0 0% 100%;
|
| 32 |
+
|
| 33 |
+
--destructive: 0 84% 60%;
|
| 34 |
+
--destructive-foreground: 0 0% 100%;
|
| 35 |
+
|
| 36 |
+
--border: 220 13% 91%;
|
| 37 |
+
--input: 220 13% 91%;
|
| 38 |
+
--ring: 248 73% 65%;
|
| 39 |
+
--radius: 0.625rem;
|
| 40 |
+
|
| 41 |
+
/* Semantic colors */
|
| 42 |
+
--success: 142 71% 45%;
|
| 43 |
+
--success-foreground: 0 0% 100%;
|
| 44 |
+
--warning: 38 92% 50%;
|
| 45 |
+
--warning-foreground: 0 0% 100%;
|
| 46 |
+
--info: 199 89% 48%;
|
| 47 |
+
--info-foreground: 0 0% 100%;
|
| 48 |
+
|
| 49 |
+
/* Chart colors */
|
| 50 |
+
--chart-1: 248 73% 65%;
|
| 51 |
+
--chart-2: 160 60% 45%;
|
| 52 |
+
--chart-3: 30 80% 55%;
|
| 53 |
+
--chart-4: 280 65% 60%;
|
| 54 |
+
--chart-5: 340 75% 55%;
|
| 55 |
+
|
| 56 |
+
/* Neumorphism shadows - Rupam Style */
|
| 57 |
+
--neu-shadow-light: #ffffff;
|
| 58 |
+
--neu-shadow-dark: #a3b1c6;
|
| 59 |
+
--neu-distance: 20px;
|
| 60 |
+
--neu-blur: 60px;
|
| 61 |
+
--bg-primary: hsl(224, 14%, 90%);
|
| 62 |
+
--bg-secondary: hsl(220, 14%, 96%);
|
| 63 |
+
|
| 64 |
+
/* Floating blobs */
|
| 65 |
+
--blob-1: hsl(248, 73%, 65%);
|
| 66 |
+
--blob-2: hsl(280, 65%, 60%);
|
| 67 |
+
|
| 68 |
+
/* Gradient colors for premium look */
|
| 69 |
+
--gradient-start: 248 73% 65%;
|
| 70 |
+
--gradient-mid: 270 70% 60%;
|
| 71 |
+
--gradient-end: 290 65% 55%;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.dark {
|
| 75 |
+
--background: 224 71% 4%;
|
| 76 |
+
--foreground: 210 20% 98%;
|
| 77 |
+
--card: 224 71% 4%;
|
| 78 |
+
--card-foreground: 210 20% 98%;
|
| 79 |
+
--popover: 224 71% 4%;
|
| 80 |
+
--popover-foreground: 210 20% 98%;
|
| 81 |
+
|
| 82 |
+
--primary: 248 73% 70%;
|
| 83 |
+
--primary-foreground: 0 0% 100%;
|
| 84 |
+
|
| 85 |
+
--secondary: 215 28% 17%;
|
| 86 |
+
--secondary-foreground: 210 20% 98%;
|
| 87 |
+
|
| 88 |
+
--muted: 215 28% 17%;
|
| 89 |
+
--muted-foreground: 218 11% 65%;
|
| 90 |
+
|
| 91 |
+
--accent: 248 73% 70%;
|
| 92 |
+
--accent-foreground: 0 0% 100%;
|
| 93 |
+
|
| 94 |
+
--destructive: 0 63% 31%;
|
| 95 |
+
--destructive-foreground: 210 20% 98%;
|
| 96 |
+
|
| 97 |
+
--border: 215 28% 17%;
|
| 98 |
+
--input: 215 28% 17%;
|
| 99 |
+
--ring: 248 73% 70%;
|
| 100 |
+
|
| 101 |
+
/* Semantic colors (dark) */
|
| 102 |
+
--success: 142 71% 35%;
|
| 103 |
+
--success-foreground: 0 0% 100%;
|
| 104 |
+
--warning: 38 92% 40%;
|
| 105 |
+
--warning-foreground: 0 0% 100%;
|
| 106 |
+
--info: 199 89% 38%;
|
| 107 |
+
--info-foreground: 0 0% 100%;
|
| 108 |
+
|
| 109 |
+
/* Chart colors (dark) */
|
| 110 |
+
--chart-1: 248 73% 70%;
|
| 111 |
+
--chart-2: 160 50% 40%;
|
| 112 |
+
--chart-3: 30 70% 50%;
|
| 113 |
+
--chart-4: 280 55% 55%;
|
| 114 |
+
--chart-5: 340 65% 50%;
|
| 115 |
+
|
| 116 |
+
/* Neumorphism shadows (dark) */
|
| 117 |
+
--neu-shadow-light: rgba(255, 255, 255, 0.05);
|
| 118 |
+
--neu-shadow-dark: rgba(0, 0, 0, 0.5);
|
| 119 |
+
--neu-distance: 20px;
|
| 120 |
+
--neu-blur: 60px;
|
| 121 |
+
--bg-primary: hsl(224, 71%, 4%);
|
| 122 |
+
--bg-secondary: hsl(215, 28%, 17%);
|
| 123 |
+
|
| 124 |
+
/* Floating blobs */
|
| 125 |
+
--blob-1: hsl(248, 73%, 70%);
|
| 126 |
+
--blob-2: hsl(280, 55%, 55%);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* ───────────────────────────────────────────────
|
| 130 |
+
Theme Mapping (Tailwind v4)
|
| 131 |
+
─────────────────────────────────────────────── */
|
| 132 |
+
|
| 133 |
+
@theme inline {
|
| 134 |
+
--color-background: hsl(var(--background));
|
| 135 |
+
--color-foreground: hsl(var(--foreground));
|
| 136 |
+
--color-card: hsl(var(--card));
|
| 137 |
+
--color-card-foreground: hsl(var(--card-foreground));
|
| 138 |
+
--color-popover: hsl(var(--popover));
|
| 139 |
+
--color-popover-foreground: hsl(var(--popover-foreground));
|
| 140 |
+
--color-primary: hsl(var(--primary));
|
| 141 |
+
--color-primary-foreground: hsl(var(--primary-foreground));
|
| 142 |
+
--color-secondary: hsl(var(--secondary));
|
| 143 |
+
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
| 144 |
+
--color-muted: hsl(var(--muted));
|
| 145 |
+
--color-muted-foreground: hsl(var(--muted-foreground));
|
| 146 |
+
--color-accent: hsl(var(--accent));
|
| 147 |
+
--color-accent-foreground: hsl(var(--accent-foreground));
|
| 148 |
+
--color-destructive: hsl(var(--destructive));
|
| 149 |
+
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
| 150 |
+
--color-border: hsl(var(--border));
|
| 151 |
+
--color-input: hsl(var(--input));
|
| 152 |
+
--color-ring: hsl(var(--ring));
|
| 153 |
+
--color-chart-1: hsl(var(--chart-1));
|
| 154 |
+
--color-chart-2: hsl(var(--chart-2));
|
| 155 |
+
--color-chart-3: hsl(var(--chart-3));
|
| 156 |
+
--color-chart-4: hsl(var(--chart-4));
|
| 157 |
+
--color-chart-5: hsl(var(--chart-5));
|
| 158 |
+
|
| 159 |
+
/* Semantic */
|
| 160 |
+
--color-success: hsl(var(--success));
|
| 161 |
+
--color-success-foreground: hsl(var(--success-foreground));
|
| 162 |
+
--color-warning: hsl(var(--warning));
|
| 163 |
+
--color-warning-foreground: hsl(var(--warning-foreground));
|
| 164 |
+
--color-info: hsl(var(--info));
|
| 165 |
+
--color-info-foreground: hsl(var(--info-foreground));
|
| 166 |
+
|
| 167 |
+
/* Radius */
|
| 168 |
+
--radius-sm: calc(var(--radius) - 4px);
|
| 169 |
+
--radius-md: calc(var(--radius) - 2px);
|
| 170 |
+
--radius-lg: var(--radius);
|
| 171 |
+
--radius-xl: calc(var(--radius) + 4px);
|
| 172 |
+
--radius-2xl: calc(var(--radius) + 8px);
|
| 173 |
+
--radius-3xl: calc(var(--radius) + 12px);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
/* ───────────────────────────────────────────────
|
| 177 |
+
Utilities
|
| 178 |
+
─────────────────────────────────────────────── */
|
| 179 |
+
|
| 180 |
+
@layer utilities {
|
| 181 |
+
.container {
|
| 182 |
+
margin-inline: auto;
|
| 183 |
+
padding-inline: 1.5rem;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
@media (min-width: 640px) {
|
| 187 |
+
.container {
|
| 188 |
+
max-width: 640px;
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
@media (min-width: 768px) {
|
| 193 |
+
.container {
|
| 194 |
+
max-width: 768px;
|
| 195 |
+
padding-inline: 2rem;
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
@media (min-width: 1024px) {
|
| 200 |
+
.container {
|
| 201 |
+
max-width: 1024px;
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
@media (min-width: 1280px) {
|
| 206 |
+
.container {
|
| 207 |
+
max-width: 1280px;
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/* Smooth scroll behavior */
|
| 212 |
+
.smooth-scroll {
|
| 213 |
+
scroll-behavior: smooth;
|
| 214 |
+
-webkit-overflow-scrolling: touch;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
/* Hide scrollbar but keep functionality */
|
| 218 |
+
.scrollbar-hide {
|
| 219 |
+
-ms-overflow-style: none;
|
| 220 |
+
scrollbar-width: none;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.scrollbar-hide::-webkit-scrollbar {
|
| 224 |
+
display: none;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/* Better focus styles */
|
| 228 |
+
.focus-ring {
|
| 229 |
+
@apply outline-none ring-2 ring-primary/50 ring-offset-2;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
/* Text balance for better typography */
|
| 233 |
+
.text-balance {
|
| 234 |
+
text-wrap: balance;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/* Glass effect */
|
| 238 |
+
.glass {
|
| 239 |
+
background: hsl(var(--background) / 0.8);
|
| 240 |
+
backdrop-filter: blur(12px);
|
| 241 |
+
-webkit-backdrop-filter: blur(12px);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
/* Gradient border */
|
| 245 |
+
.gradient-border {
|
| 246 |
+
position: relative;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.gradient-border::before {
|
| 250 |
+
content: '';
|
| 251 |
+
position: absolute;
|
| 252 |
+
inset: 0;
|
| 253 |
+
border-radius: inherit;
|
| 254 |
+
padding: 1px;
|
| 255 |
+
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--accent)));
|
| 256 |
+
-webkit-mask:
|
| 257 |
+
linear-gradient(#fff 0 0) content-box,
|
| 258 |
+
linear-gradient(#fff 0 0);
|
| 259 |
+
-webkit-mask-composite: xor;
|
| 260 |
+
mask-composite: exclude;
|
| 261 |
+
pointer-events: none;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
/* Section spacing */
|
| 265 |
+
.section-py {
|
| 266 |
+
@apply py-16 md:py-20 lg:py-24;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
/* Hide on scroll utility */
|
| 270 |
+
.hide-on-scroll {
|
| 271 |
+
transform: translateY(0);
|
| 272 |
+
transition: transform 0.3s ease;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.hide-on-scroll.scrolled {
|
| 276 |
+
transform: translateY(-100%);
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
/* ───────────────────────────────────────────────
|
| 281 |
+
Base Layer — Typography Scale & Defaults
|
| 282 |
+
─────────────────────────────────────────────── */
|
| 283 |
+
|
| 284 |
+
@layer base {
|
| 285 |
+
* {
|
| 286 |
+
@apply border-border outline-ring/50;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
body {
|
| 290 |
+
@apply bg-background text-foreground;
|
| 291 |
+
font-feature-settings: "rlig" 1, "calt" 1;
|
| 292 |
+
font-optical-sizing: auto;
|
| 293 |
+
-webkit-font-smoothing: antialiased;
|
| 294 |
+
-moz-osx-font-smoothing: grayscale;
|
| 295 |
+
text-rendering: optimizeLegibility;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
h1, h2, h3, h4, h5, h6 {
|
| 299 |
+
font-weight: 700;
|
| 300 |
+
letter-spacing: -0.02em;
|
| 301 |
+
line-height: 1.2;
|
| 302 |
+
font-family: var(--font-heading), var(--font-sans);
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
h1 {
|
| 306 |
+
font-size: clamp(2.5rem, 5vw, 4rem);
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
h2 {
|
| 310 |
+
font-size: clamp(2rem, 4vw, 3rem);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
h3 {
|
| 314 |
+
font-size: clamp(1.5rem, 3vw, 2rem);
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
h4 {
|
| 318 |
+
font-size: clamp(1.25rem, 2.5vw, 1.5rem);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
/* Font family utilities */
|
| 322 |
+
.font-heading {
|
| 323 |
+
font-family: var(--font-heading), var(--font-sans);
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
/* Utility typography classes */
|
| 327 |
+
.text-body {
|
| 328 |
+
@apply text-base leading-relaxed;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.text-caption {
|
| 332 |
+
@apply text-sm text-muted-foreground;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.text-overline {
|
| 336 |
+
@apply text-xs font-semibold uppercase tracking-widest text-muted-foreground;
|
| 337 |
+
}
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
/* ───────────────────────────────────────────────
|
| 341 |
+
CSS Animations (replacing Framer Motion)
|
| 342 |
+
─────────────────────────────────────────────── */
|
| 343 |
+
|
| 344 |
+
@keyframes fadeIn {
|
| 345 |
+
from {
|
| 346 |
+
opacity: 0;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
to {
|
| 350 |
+
opacity: 1;
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
@keyframes fadeInUp {
|
| 355 |
+
from {
|
| 356 |
+
opacity: 0;
|
| 357 |
+
transform: translateY(20px);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
to {
|
| 361 |
+
opacity: 1;
|
| 362 |
+
transform: translateY(0);
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
@keyframes fadeInDown {
|
| 367 |
+
from {
|
| 368 |
+
opacity: 0;
|
| 369 |
+
transform: translateY(-12px);
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
to {
|
| 373 |
+
opacity: 1;
|
| 374 |
+
transform: translateY(0);
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
@keyframes scaleIn {
|
| 379 |
+
from {
|
| 380 |
+
opacity: 0;
|
| 381 |
+
transform: scale(0.95);
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
to {
|
| 385 |
+
opacity: 1;
|
| 386 |
+
transform: scale(1);
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
@keyframes slideInLeft {
|
| 391 |
+
from {
|
| 392 |
+
opacity: 0;
|
| 393 |
+
transform: translateX(-20px);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
to {
|
| 397 |
+
opacity: 1;
|
| 398 |
+
transform: translateX(0);
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
@keyframes slideInRight {
|
| 403 |
+
from {
|
| 404 |
+
opacity: 0;
|
| 405 |
+
transform: translateX(20px);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
to {
|
| 409 |
+
opacity: 1;
|
| 410 |
+
transform: translateX(0);
|
| 411 |
+
}
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
@keyframes shimmer {
|
| 415 |
+
0% {
|
| 416 |
+
background-position: -1000px 0;
|
| 417 |
+
}
|
| 418 |
+
100% {
|
| 419 |
+
background-position: 1000px 0;
|
| 420 |
+
}
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
@keyframes float {
|
| 424 |
+
0%, 100% {
|
| 425 |
+
transform: translateY(0px);
|
| 426 |
+
}
|
| 427 |
+
50% {
|
| 428 |
+
transform: translateY(-10px);
|
| 429 |
+
}
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
@keyframes pulse-glow {
|
| 433 |
+
0%, 100% {
|
| 434 |
+
opacity: 1;
|
| 435 |
+
transform: scale(1);
|
| 436 |
+
}
|
| 437 |
+
50% {
|
| 438 |
+
opacity: 0.8;
|
| 439 |
+
transform: scale(1.05);
|
| 440 |
+
}
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
@keyframes gradient-shift {
|
| 444 |
+
0%, 100% {
|
| 445 |
+
background-position: 0% 50%;
|
| 446 |
+
}
|
| 447 |
+
50% {
|
| 448 |
+
background-position: 100% 50%;
|
| 449 |
+
}
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
.animate-fade-in {
|
| 453 |
+
animation: fadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
.animate-fade-in-up {
|
| 457 |
+
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.animate-fade-in-down {
|
| 461 |
+
animation: fadeInDown 0.5s ease-out both;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.animate-scale-in {
|
| 465 |
+
animation: scaleIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.animate-slide-in-left {
|
| 469 |
+
animation: slideInLeft 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
.animate-slide-in-right {
|
| 473 |
+
animation: slideInRight 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
.animate-shimmer {
|
| 477 |
+
background: linear-gradient(
|
| 478 |
+
90deg,
|
| 479 |
+
transparent 0%,
|
| 480 |
+
rgba(255, 255, 255, 0.1) 50%,
|
| 481 |
+
transparent 100%
|
| 482 |
+
);
|
| 483 |
+
background-size: 1000px 100%;
|
| 484 |
+
animation: shimmer 3s infinite;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
.animate-float {
|
| 488 |
+
animation: float 3s ease-in-out infinite;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.animate-pulse-glow {
|
| 492 |
+
animation: pulse-glow 2s ease-in-out infinite;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
.animate-gradient {
|
| 496 |
+
background-size: 200% 200%;
|
| 497 |
+
animation: gradient-shift 8s ease infinite;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
/* Stagger delays */
|
| 501 |
+
.animate-delay-1 {
|
| 502 |
+
animation-delay: 0.1s;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
.animate-delay-2 {
|
| 506 |
+
animation-delay: 0.2s;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
.animate-delay-3 {
|
| 510 |
+
animation-delay: 0.3s;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
.animate-delay-4 {
|
| 514 |
+
animation-delay: 0.4s;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.animate-delay-5 {
|
| 518 |
+
animation-delay: 0.5s;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.animate-delay-6 {
|
| 522 |
+
animation-delay: 0.6s;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.animate-delay-7 {
|
| 526 |
+
animation-delay: 0.7s;
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
/* Hover effects */
|
| 530 |
+
.hover-lift {
|
| 531 |
+
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
.hover-lift:hover {
|
| 535 |
+
transform: translateY(-4px);
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
.hover-glow {
|
| 539 |
+
transition: box-shadow 0.3s ease;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
.hover-glow:hover {
|
| 543 |
+
box-shadow: 0 0 20px rgba(var(--primary), 0.3);
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
/* Neumorphism styles */
|
| 547 |
+
.neu-flat {
|
| 548 |
+
background: hsl(var(--background));
|
| 549 |
+
box-shadow:
|
| 550 |
+
var(--neu-distance) var(--neu-distance) var(--neu-blur) var(--neu-shadow-dark),
|
| 551 |
+
calc(var(--neu-distance) * -1) calc(var(--neu-distance) * -1) var(--neu-blur) var(--neu-shadow-light);
|
| 552 |
+
border: none;
|
| 553 |
+
transition: all 0.3s ease;
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
.neu-flat:hover {
|
| 557 |
+
box-shadow:
|
| 558 |
+
calc(var(--neu-distance) * 1.25) calc(var(--neu-distance) * 1.25) calc(var(--neu-blur) * 1.25) var(--neu-shadow-dark),
|
| 559 |
+
calc(var(--neu-distance) * -1.25) calc(var(--neu-distance) * -1.25) calc(var(--neu-blur) * 1.25) var(--neu-shadow-light);
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
.neu-pressed {
|
| 563 |
+
background: hsl(var(--background));
|
| 564 |
+
box-shadow:
|
| 565 |
+
inset var(--neu-distance) var(--neu-distance) var(--neu-blur) var(--neu-shadow-dark),
|
| 566 |
+
inset calc(var(--neu-distance) * -1) calc(var(--neu-distance) * -1) var(--neu-blur) var(--neu-shadow-light);
|
| 567 |
+
border: none;
|
| 568 |
+
transition: all 0.3s ease;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
.neu-convex {
|
| 572 |
+
background: linear-gradient(145deg, hsl(var(--background)), hsl(var(--muted)));
|
| 573 |
+
box-shadow:
|
| 574 |
+
var(--neu-distance) var(--neu-distance) var(--neu-blur) var(--neu-shadow-dark),
|
| 575 |
+
calc(var(--neu-distance) * -1) calc(var(--neu-distance) * -1) var(--neu-blur) var(--neu-shadow-light);
|
| 576 |
+
border: none;
|
| 577 |
+
transition: all 0.3s ease;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
.neu-convex:hover {
|
| 581 |
+
box-shadow:
|
| 582 |
+
inset 4px 4px 8px var(--neu-shadow-dark),
|
| 583 |
+
inset -4px -4px 8px var(--neu-shadow-light);
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
.neu-concave {
|
| 587 |
+
background: linear-gradient(145deg, hsl(var(--muted)), hsl(var(--background)));
|
| 588 |
+
box-shadow:
|
| 589 |
+
inset 5px 5px 10px var(--neu-shadow-dark),
|
| 590 |
+
inset -5px -5px 10px var(--neu-shadow-light);
|
| 591 |
+
border: none;
|
| 592 |
+
transition: all 0.3s ease;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
.neu-button {
|
| 596 |
+
background: hsl(var(--background));
|
| 597 |
+
box-shadow:
|
| 598 |
+
8px 8px 16px var(--neu-shadow-dark),
|
| 599 |
+
-8px -8px 16px var(--neu-shadow-light);
|
| 600 |
+
border: none;
|
| 601 |
+
transition: all 0.3s ease;
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
.neu-button:hover {
|
| 605 |
+
transform: translateY(-2px);
|
| 606 |
+
box-shadow:
|
| 607 |
+
10px 10px 20px var(--neu-shadow-dark),
|
| 608 |
+
-10px -10px 20px var(--neu-shadow-light);
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
.neu-button:active {
|
| 612 |
+
transform: translateY(0);
|
| 613 |
+
box-shadow:
|
| 614 |
+
inset 4px 4px 8px var(--neu-shadow-dark),
|
| 615 |
+
inset -4px -4px 8px var(--neu-shadow-light);
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
.neu-card {
|
| 619 |
+
background: hsl(var(--background));
|
| 620 |
+
box-shadow:
|
| 621 |
+
12px 12px 24px var(--neu-shadow-dark),
|
| 622 |
+
-12px -12px 24px var(--neu-shadow-light);
|
| 623 |
+
border: none;
|
| 624 |
+
border-radius: 30px;
|
| 625 |
+
transition: all 0.3s ease;
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
.neu-card:hover {
|
| 629 |
+
box-shadow:
|
| 630 |
+
16px 16px 32px var(--neu-shadow-dark),
|
| 631 |
+
-16px -16px 32px var(--neu-shadow-light);
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
.neu-input {
|
| 635 |
+
background: hsl(var(--background));
|
| 636 |
+
box-shadow:
|
| 637 |
+
inset 4px 4px 8px var(--neu-shadow-dark),
|
| 638 |
+
inset -4px -4px 8px var(--neu-shadow-light);
|
| 639 |
+
border: none;
|
| 640 |
+
border-radius: 12px;
|
| 641 |
+
padding: 12px 16px;
|
| 642 |
+
transition: all 0.3s ease;
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
.neu-input:focus {
|
| 646 |
+
box-shadow:
|
| 647 |
+
inset 6px 6px 12px var(--neu-shadow-dark),
|
| 648 |
+
inset -6px -6px 12px var(--neu-shadow-light);
|
| 649 |
+
outline: none;
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
/* Advanced animation utilities */
|
| 653 |
+
.animate-on-scroll {
|
| 654 |
+
opacity: 0;
|
| 655 |
+
animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
.bounce-subtle {
|
| 659 |
+
animation: bounceSubtle 2s ease-in-out infinite;
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
@keyframes bounceSubtle {
|
| 663 |
+
0%, 100% {
|
| 664 |
+
transform: translateY(0);
|
| 665 |
+
}
|
| 666 |
+
50% {
|
| 667 |
+
transform: translateY(-5px);
|
| 668 |
+
}
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
.rotate-slow {
|
| 672 |
+
animation: rotateSlow 20s linear infinite;
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
@keyframes rotateSlow {
|
| 676 |
+
from {
|
| 677 |
+
transform: rotate(0deg);
|
| 678 |
+
}
|
| 679 |
+
to {
|
| 680 |
+
transform: rotate(360deg);
|
| 681 |
+
}
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
.perspective-card {
|
| 685 |
+
perspective: 1000px;
|
| 686 |
+
transform-style: preserve-3d;
|
| 687 |
+
transition: all 0.3s ease;
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
/* Premium gradient text */
|
| 691 |
+
.gradient-text {
|
| 692 |
+
background: linear-gradient(135deg, hsl(var(--gradient-start)), hsl(var(--gradient-mid)), hsl(var(--gradient-end)));
|
| 693 |
+
background-size: 200% 200%;
|
| 694 |
+
-webkit-background-clip: text;
|
| 695 |
+
background-clip: text;
|
| 696 |
+
-webkit-text-fill-color: transparent;
|
| 697 |
+
animation: gradientShift 8s ease infinite;
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
@keyframes gradientShift {
|
| 701 |
+
0%, 100% {
|
| 702 |
+
background-position: 0% 50%;
|
| 703 |
+
}
|
| 704 |
+
50% {
|
| 705 |
+
background-position: 100% 50%;
|
| 706 |
+
}
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
/* Floating background blobs */
|
| 710 |
+
.floating-blob {
|
| 711 |
+
position: absolute;
|
| 712 |
+
border-radius: 50%;
|
| 713 |
+
filter: blur(80px);
|
| 714 |
+
opacity: 0.3;
|
| 715 |
+
animation: float 20s ease-in-out infinite;
|
| 716 |
+
pointer-events: none;
|
| 717 |
+
z-index: 0;
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
.floating-blob-1 {
|
| 721 |
+
background: var(--blob-1);
|
| 722 |
+
width: 400px;
|
| 723 |
+
height: 400px;
|
| 724 |
+
top: -200px;
|
| 725 |
+
right: -100px;
|
| 726 |
+
animation-delay: 0s;
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
.floating-blob-2 {
|
| 730 |
+
background: var(--blob-2);
|
| 731 |
+
width: 350px;
|
| 732 |
+
height: 350px;
|
| 733 |
+
bottom: -150px;
|
| 734 |
+
left: -100px;
|
| 735 |
+
animation-delay: 5s;
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
@keyframes float {
|
| 739 |
+
0%, 100% {
|
| 740 |
+
transform: translate(0, 0) scale(1);
|
| 741 |
+
}
|
| 742 |
+
33% {
|
| 743 |
+
transform: translate(30px, -30px) scale(1.1);
|
| 744 |
+
}
|
| 745 |
+
66% {
|
| 746 |
+
transform: translate(-20px, 20px) scale(0.9);
|
| 747 |
+
}
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
/* Shimmer effect for premium feel */
|
| 751 |
+
.shimmer {
|
| 752 |
+
position: relative;
|
| 753 |
+
overflow: hidden;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
.shimmer::after {
|
| 757 |
+
content: '';
|
| 758 |
+
position: absolute;
|
| 759 |
+
top: 0;
|
| 760 |
+
left: -100%;
|
| 761 |
+
width: 100%;
|
| 762 |
+
height: 100%;
|
| 763 |
+
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
| 764 |
+
animation: shimmer 3s infinite;
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
@keyframes shimmer {
|
| 768 |
+
0% {
|
| 769 |
+
left: -100%;
|
| 770 |
+
}
|
| 771 |
+
100% {
|
| 772 |
+
left: 100%;
|
| 773 |
+
}
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
/* Glow effect on hover */
|
| 777 |
+
.glow-on-hover {
|
| 778 |
+
position: relative;
|
| 779 |
+
transition: all 0.3s ease;
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
.glow-on-hover::before {
|
| 783 |
+
content: '';
|
| 784 |
+
position: absolute;
|
| 785 |
+
inset: -2px;
|
| 786 |
+
border-radius: inherit;
|
| 787 |
+
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--accent)));
|
| 788 |
+
opacity: 0;
|
| 789 |
+
transition: opacity 0.3s ease;
|
| 790 |
+
z-index: -1;
|
| 791 |
+
filter: blur(10px);
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
.glow-on-hover:hover::before {
|
| 795 |
+
opacity: 0.6;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
/* 3D Card Tilt Effect */
|
| 799 |
+
.card-3d {
|
| 800 |
+
transform-style: preserve-3d;
|
| 801 |
+
transition: transform 0.3s ease;
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
.card-3d:hover {
|
| 805 |
+
transform: translateY(-8px);
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
/* Scroll reveal animations */
|
| 809 |
+
@keyframes reveal-up {
|
| 810 |
+
from {
|
| 811 |
+
opacity: 0;
|
| 812 |
+
transform: translateY(40px);
|
| 813 |
+
}
|
| 814 |
+
to {
|
| 815 |
+
opacity: 1;
|
| 816 |
+
transform: translateY(0);
|
| 817 |
+
}
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
@keyframes reveal-left {
|
| 821 |
+
from {
|
| 822 |
+
opacity: 0;
|
| 823 |
+
transform: translateX(-40px);
|
| 824 |
+
}
|
| 825 |
+
to {
|
| 826 |
+
opacity: 1;
|
| 827 |
+
transform: translateX(0);
|
| 828 |
+
}
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
@keyframes reveal-right {
|
| 832 |
+
from {
|
| 833 |
+
opacity: 0;
|
| 834 |
+
transform: translateX(40px);
|
| 835 |
+
}
|
| 836 |
+
to {
|
| 837 |
+
opacity: 1;
|
| 838 |
+
transform: translateX(0);
|
| 839 |
+
}
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
@keyframes scale-in {
|
| 843 |
+
from {
|
| 844 |
+
opacity: 0;
|
| 845 |
+
transform: scale(0.9);
|
| 846 |
+
}
|
| 847 |
+
to {
|
| 848 |
+
opacity: 1;
|
| 849 |
+
transform: scale(1);
|
| 850 |
+
}
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
.reveal-up {
|
| 854 |
+
animation: reveal-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
.reveal-left {
|
| 858 |
+
animation: reveal-left 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
.reveal-right {
|
| 862 |
+
animation: reveal-right 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
.scale-in {
|
| 866 |
+
animation: scale-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
/* Magnetic button effect */
|
| 870 |
+
.magnetic-button {
|
| 871 |
+
position: relative;
|
| 872 |
+
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
/* Text gradient animation */
|
| 876 |
+
.text-shimmer {
|
| 877 |
+
background: linear-gradient(90deg,
|
| 878 |
+
hsl(var(--foreground)) 0%,
|
| 879 |
+
hsl(var(--primary)) 50%,
|
| 880 |
+
hsl(var(--foreground)) 100%
|
| 881 |
+
);
|
| 882 |
+
background-size: 200% auto;
|
| 883 |
+
-webkit-background-clip: text;
|
| 884 |
+
background-clip: text;
|
| 885 |
+
-webkit-text-fill-color: transparent;
|
| 886 |
+
animation: text-shimmer 3s linear infinite;
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
@keyframes text-shimmer {
|
| 890 |
+
to {
|
| 891 |
+
background-position: 200% center;
|
| 892 |
+
}
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
/* Pulse animation for icons */
|
| 896 |
+
.pulse-icon {
|
| 897 |
+
animation: pulse-icon 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
@keyframes pulse-icon {
|
| 901 |
+
0%, 100% {
|
| 902 |
+
opacity: 1;
|
| 903 |
+
transform: scale(1);
|
| 904 |
+
}
|
| 905 |
+
50% {
|
| 906 |
+
opacity: 0.8;
|
| 907 |
+
transform: scale(1.05);
|
| 908 |
+
}
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
/* Stagger children animation */
|
| 912 |
+
.stagger-children > * {
|
| 913 |
+
opacity: 0;
|
| 914 |
+
animation: reveal-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
.stagger-children > *:nth-child(1) { animation-delay: 0.1s; }
|
| 918 |
+
.stagger-children > *:nth-child(2) { animation-delay: 0.2s; }
|
| 919 |
+
.stagger-children > *:nth-child(3) { animation-delay: 0.3s; }
|
| 920 |
+
.stagger-children > *:nth-child(4) { animation-delay: 0.4s; }
|
| 921 |
+
.stagger-children > *:nth-child(5) { animation-delay: 0.5s; }
|
| 922 |
+
.stagger-children > *:nth-child(6) { animation-delay: 0.6s; }
|
| 923 |
+
|
| 924 |
+
/* ───────────────────────────────────────────────
|
| 925 |
+
Creative UI Enhancements
|
| 926 |
+
─────────────────────────────────────────────── */
|
| 927 |
+
|
| 928 |
+
/* Animated gradient mesh background */
|
| 929 |
+
.gradient-mesh {
|
| 930 |
+
background:
|
| 931 |
+
radial-gradient(at 40% 20%, hsla(248, 73%, 65%, 0.3) 0px, transparent 50%),
|
| 932 |
+
radial-gradient(at 80% 0%, hsla(280, 65%, 60%, 0.2) 0px, transparent 50%),
|
| 933 |
+
radial-gradient(at 0% 50%, hsla(199, 89%, 48%, 0.2) 0px, transparent 50%),
|
| 934 |
+
radial-gradient(at 80% 50%, hsla(340, 75%, 55%, 0.15) 0px, transparent 50%),
|
| 935 |
+
radial-gradient(at 0% 100%, hsla(30, 80%, 55%, 0.2) 0px, transparent 50%),
|
| 936 |
+
radial-gradient(at 80% 100%, hsla(160, 60%, 45%, 0.15) 0px, transparent 50%);
|
| 937 |
+
animation: gradient-mesh-move 20s ease infinite;
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
@keyframes gradient-mesh-move {
|
| 941 |
+
0%, 100% {
|
| 942 |
+
background-position: 0% 0%, 100% 0%, 0% 50%, 100% 50%, 0% 100%, 100% 100%;
|
| 943 |
+
}
|
| 944 |
+
25% {
|
| 945 |
+
background-position: 100% 0%, 0% 50%, 50% 0%, 0% 100%, 50% 50%, 0% 0%;
|
| 946 |
+
}
|
| 947 |
+
50% {
|
| 948 |
+
background-position: 100% 100%, 0% 0%, 100% 50%, 50% 0%, 100% 0%, 50% 50%;
|
| 949 |
+
}
|
| 950 |
+
75% {
|
| 951 |
+
background-position: 0% 100%, 100% 50%, 0% 0%, 100% 0%, 50% 50%, 100% 100%;
|
| 952 |
+
}
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
/* Noise texture overlay for depth */
|
| 956 |
+
.noise-overlay {
|
| 957 |
+
position: relative;
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
.noise-overlay::before {
|
| 961 |
+
content: '';
|
| 962 |
+
position: absolute;
|
| 963 |
+
inset: 0;
|
| 964 |
+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
| 965 |
+
opacity: 0.03;
|
| 966 |
+
pointer-events: none;
|
| 967 |
+
z-index: 1;
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
/* Morphing blob shapes */
|
| 971 |
+
@keyframes morph-blob {
|
| 972 |
+
0%, 100% {
|
| 973 |
+
border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%;
|
| 974 |
+
}
|
| 975 |
+
25% {
|
| 976 |
+
border-radius: 30% 60% 70% 40% / 50% 60% 30% 60%;
|
| 977 |
+
}
|
| 978 |
+
50% {
|
| 979 |
+
border-radius: 50% 60% 30% 60% / 30% 60% 70% 40%;
|
| 980 |
+
}
|
| 981 |
+
75% {
|
| 982 |
+
border-radius: 60% 40% 60% 30% / 70% 30% 50% 60%;
|
| 983 |
+
}
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
.morphing-blob {
|
| 987 |
+
animation: morph-blob 15s ease-in-out infinite;
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
/* Floating particles effect */
|
| 991 |
+
@keyframes float-particle {
|
| 992 |
+
0%, 100% {
|
| 993 |
+
transform: translateY(0) translateX(0) rotate(0deg);
|
| 994 |
+
opacity: 0.3;
|
| 995 |
+
}
|
| 996 |
+
25% {
|
| 997 |
+
transform: translateY(-100px) translateX(50px) rotate(90deg);
|
| 998 |
+
opacity: 0.6;
|
| 999 |
+
}
|
| 1000 |
+
50% {
|
| 1001 |
+
transform: translateY(-200px) translateX(-30px) rotate(180deg);
|
| 1002 |
+
opacity: 0.3;
|
| 1003 |
+
}
|
| 1004 |
+
75% {
|
| 1005 |
+
transform: translateY(-100px) translateX(-50px) rotate(270deg);
|
| 1006 |
+
opacity: 0.6;
|
| 1007 |
+
}
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
.particle {
|
| 1011 |
+
position: absolute;
|
| 1012 |
+
width: 6px;
|
| 1013 |
+
height: 6px;
|
| 1014 |
+
border-radius: 50%;
|
| 1015 |
+
background: hsl(var(--primary));
|
| 1016 |
+
opacity: 0.3;
|
| 1017 |
+
animation: float-particle 20s ease-in-out infinite;
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
/* Enhanced glass effect with animated border */
|
| 1021 |
+
.glass-card {
|
| 1022 |
+
background: hsl(var(--background) / 0.6);
|
| 1023 |
+
backdrop-filter: blur(20px);
|
| 1024 |
+
-webkit-backdrop-filter: blur(20px);
|
| 1025 |
+
border: 1px solid hsl(var(--border) / 0.3);
|
| 1026 |
+
position: relative;
|
| 1027 |
+
overflow: hidden;
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
.glass-card::before {
|
| 1031 |
+
content: '';
|
| 1032 |
+
position: absolute;
|
| 1033 |
+
inset: 0;
|
| 1034 |
+
background: linear-gradient(
|
| 1035 |
+
135deg,
|
| 1036 |
+
hsl(var(--primary) / 0.1) 0%,
|
| 1037 |
+
transparent 50%,
|
| 1038 |
+
hsl(var(--accent) / 0.1) 100%
|
| 1039 |
+
);
|
| 1040 |
+
opacity: 0;
|
| 1041 |
+
transition: opacity 0.5s ease;
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
+
.glass-card:hover::before {
|
| 1045 |
+
opacity: 1;
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
/* Animated gradient border */
|
| 1049 |
+
.animated-border {
|
| 1050 |
+
position: relative;
|
| 1051 |
+
background: hsl(var(--background));
|
| 1052 |
+
border-radius: var(--radius-lg);
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
.animated-border::before {
|
| 1056 |
+
content: '';
|
| 1057 |
+
position: absolute;
|
| 1058 |
+
inset: -2px;
|
| 1059 |
+
border-radius: inherit;
|
| 1060 |
+
background: conic-gradient(
|
| 1061 |
+
from var(--angle, 0deg),
|
| 1062 |
+
hsl(var(--primary)),
|
| 1063 |
+
hsl(280, 65%, 60%),
|
| 1064 |
+
hsl(320, 75%, 55%),
|
| 1065 |
+
hsl(var(--primary))
|
| 1066 |
+
);
|
| 1067 |
+
z-index: -1;
|
| 1068 |
+
animation: rotate-gradient 4s linear infinite;
|
| 1069 |
+
}
|
| 1070 |
+
|
| 1071 |
+
@keyframes rotate-gradient {
|
| 1072 |
+
to {
|
| 1073 |
+
--angle: 360deg;
|
| 1074 |
+
}
|
| 1075 |
+
}
|
| 1076 |
+
|
| 1077 |
+
@property --angle {
|
| 1078 |
+
syntax: '<angle>';
|
| 1079 |
+
initial-value: 0deg;
|
| 1080 |
+
inherits: false;
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
/* Split color text effect */
|
| 1084 |
+
.split-color-text {
|
| 1085 |
+
background: linear-gradient(
|
| 1086 |
+
135deg,
|
| 1087 |
+
hsl(var(--foreground)) 0%,
|
| 1088 |
+
hsl(var(--foreground)) 50%,
|
| 1089 |
+
hsl(var(--primary)) 50%,
|
| 1090 |
+
hsl(var(--primary)) 100%
|
| 1091 |
+
);
|
| 1092 |
+
background-size: 200% 100%;
|
| 1093 |
+
background-position: 100% 0;
|
| 1094 |
+
-webkit-background-clip: text;
|
| 1095 |
+
background-clip: text;
|
| 1096 |
+
-webkit-text-fill-color: transparent;
|
| 1097 |
+
transition: background-position 0.5s ease;
|
| 1098 |
+
}
|
| 1099 |
+
|
| 1100 |
+
.split-color-text:hover {
|
| 1101 |
+
background-position: 0 0;
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
/* 3D perspective card with depth */
|
| 1105 |
+
.card-3d-depth {
|
| 1106 |
+
transform-style: preserve-3d;
|
| 1107 |
+
perspective: 1000px;
|
| 1108 |
+
transition: transform 0.6s cubic-bezier(0.23, 1, 0.32, 1);
|
| 1109 |
+
}
|
| 1110 |
+
|
| 1111 |
+
.card-3d-depth:hover {
|
| 1112 |
+
transform: rotateY(-5deg) rotateX(5deg) translateZ(20px);
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
+
.card-3d-depth::before {
|
| 1116 |
+
content: '';
|
| 1117 |
+
position: absolute;
|
| 1118 |
+
inset: 0;
|
| 1119 |
+
background: linear-gradient(
|
| 1120 |
+
135deg,
|
| 1121 |
+
hsl(var(--primary) / 0.1) 0%,
|
| 1122 |
+
transparent 100%
|
| 1123 |
+
);
|
| 1124 |
+
opacity: 0;
|
| 1125 |
+
transition: opacity 0.3s ease;
|
| 1126 |
+
border-radius: inherit;
|
| 1127 |
+
z-index: -1;
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
+
.card-3d-depth:hover::before {
|
| 1131 |
+
opacity: 1;
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
/* Spotlight hover effect */
|
| 1135 |
+
.spotlight-card {
|
| 1136 |
+
position: relative;
|
| 1137 |
+
overflow: hidden;
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
.spotlight-card::after {
|
| 1141 |
+
content: '';
|
| 1142 |
+
position: absolute;
|
| 1143 |
+
width: 200px;
|
| 1144 |
+
height: 200px;
|
| 1145 |
+
background: radial-gradient(
|
| 1146 |
+
circle,
|
| 1147 |
+
hsl(var(--primary) / 0.15) 0%,
|
| 1148 |
+
transparent 70%
|
| 1149 |
+
);
|
| 1150 |
+
border-radius: 50%;
|
| 1151 |
+
pointer-events: none;
|
| 1152 |
+
opacity: 0;
|
| 1153 |
+
transform: translate(-50%, -50%);
|
| 1154 |
+
transition: opacity 0.3s ease;
|
| 1155 |
+
}
|
| 1156 |
+
|
| 1157 |
+
.spotlight-card:hover::after {
|
| 1158 |
+
opacity: 1;
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
/* Typewriter effect */
|
| 1162 |
+
.typewriter {
|
| 1163 |
+
overflow: hidden;
|
| 1164 |
+
border-right: 2px solid hsl(var(--primary));
|
| 1165 |
+
white-space: nowrap;
|
| 1166 |
+
animation: typing 3.5s steps(40, end), blink-caret 0.75s step-end infinite;
|
| 1167 |
+
}
|
| 1168 |
+
|
| 1169 |
+
@keyframes typing {
|
| 1170 |
+
from { width: 0; }
|
| 1171 |
+
to { width: 100%; }
|
| 1172 |
+
}
|
| 1173 |
+
|
| 1174 |
+
@keyframes blink-caret {
|
| 1175 |
+
from, to { border-color: transparent; }
|
| 1176 |
+
50% { border-color: hsl(var(--primary)); }
|
| 1177 |
+
}
|
| 1178 |
+
|
| 1179 |
+
/* Glitch text effect */
|
| 1180 |
+
.glitch-text {
|
| 1181 |
+
position: relative;
|
| 1182 |
+
}
|
| 1183 |
+
|
| 1184 |
+
.glitch-text::before,
|
| 1185 |
+
.glitch-text::after {
|
| 1186 |
+
content: attr(data-text);
|
| 1187 |
+
position: absolute;
|
| 1188 |
+
top: 0;
|
| 1189 |
+
left: 0;
|
| 1190 |
+
width: 100%;
|
| 1191 |
+
height: 100%;
|
| 1192 |
+
}
|
| 1193 |
+
|
| 1194 |
+
.glitch-text::before {
|
| 1195 |
+
animation: glitch-1 0.3s infinite linear alternate-reverse;
|
| 1196 |
+
color: hsl(0, 100%, 50%);
|
| 1197 |
+
z-index: -1;
|
| 1198 |
+
}
|
| 1199 |
+
|
| 1200 |
+
.glitch-text::after {
|
| 1201 |
+
animation: glitch-2 0.3s infinite linear alternate-reverse;
|
| 1202 |
+
color: hsl(180, 100%, 50%);
|
| 1203 |
+
z-index: -2;
|
| 1204 |
+
}
|
| 1205 |
+
|
| 1206 |
+
@keyframes glitch-1 {
|
| 1207 |
+
0% { clip-path: inset(20% 0 60% 0); transform: translate(-2px, 2px); }
|
| 1208 |
+
20% { clip-path: inset(60% 0 20% 0); transform: translate(2px, -2px); }
|
| 1209 |
+
40% { clip-path: inset(40% 0 40% 0); transform: translate(-2px, 0); }
|
| 1210 |
+
60% { clip-path: inset(80% 0 0% 0); transform: translate(0, 2px); }
|
| 1211 |
+
80% { clip-path: inset(0% 0 80% 0); transform: translate(2px, 0); }
|
| 1212 |
+
100% { clip-path: inset(50% 0 30% 0); transform: translate(-2px, -2px); }
|
| 1213 |
+
}
|
| 1214 |
+
|
| 1215 |
+
@keyframes glitch-2 {
|
| 1216 |
+
0% { clip-path: inset(60% 0 20% 0); transform: translate(2px, -2px); }
|
| 1217 |
+
20% { clip-path: inset(20% 0 60% 0); transform: translate(-2px, 2px); }
|
| 1218 |
+
40% { clip-path: inset(80% 0 0% 0); transform: translate(2px, 0); }
|
| 1219 |
+
60% { clip-path: inset(40% 0 40% 0); transform: translate(0, -2px); }
|
| 1220 |
+
80% { clip-path: inset(50% 0 30% 0); transform: translate(-2px, 0); }
|
| 1221 |
+
100% { clip-path: inset(0% 0 80% 0); transform: translate(2px, 2px); }
|
| 1222 |
+
}
|
| 1223 |
+
|
| 1224 |
+
/* Neon glow effect */
|
| 1225 |
+
.neon-glow {
|
| 1226 |
+
text-shadow:
|
| 1227 |
+
0 0 5px hsl(var(--primary)),
|
| 1228 |
+
0 0 10px hsl(var(--primary)),
|
| 1229 |
+
0 0 20px hsl(var(--primary)),
|
| 1230 |
+
0 0 40px hsl(var(--primary));
|
| 1231 |
+
animation: neon-pulse 2s ease-in-out infinite;
|
| 1232 |
+
}
|
| 1233 |
+
|
| 1234 |
+
@keyframes neon-pulse {
|
| 1235 |
+
0%, 100% {
|
| 1236 |
+
text-shadow:
|
| 1237 |
+
0 0 5px hsl(var(--primary)),
|
| 1238 |
+
0 0 10px hsl(var(--primary)),
|
| 1239 |
+
0 0 20px hsl(var(--primary)),
|
| 1240 |
+
0 0 40px hsl(var(--primary));
|
| 1241 |
+
}
|
| 1242 |
+
50% {
|
| 1243 |
+
text-shadow:
|
| 1244 |
+
0 0 2px hsl(var(--primary)),
|
| 1245 |
+
0 0 5px hsl(var(--primary)),
|
| 1246 |
+
0 0 10px hsl(var(--primary)),
|
| 1247 |
+
0 0 20px hsl(var(--primary));
|
| 1248 |
+
}
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
/* Ripple button effect */
|
| 1252 |
+
.ripple-button {
|
| 1253 |
+
position: relative;
|
| 1254 |
+
overflow: hidden;
|
| 1255 |
+
}
|
| 1256 |
+
|
| 1257 |
+
.ripple-button::after {
|
| 1258 |
+
content: '';
|
| 1259 |
+
position: absolute;
|
| 1260 |
+
width: 100%;
|
| 1261 |
+
height: 100%;
|
| 1262 |
+
top: 0;
|
| 1263 |
+
left: 0;
|
| 1264 |
+
background: radial-gradient(circle, hsl(var(--primary-foreground) / 0.3) 10%, transparent 10%);
|
| 1265 |
+
background-size: 0 0;
|
| 1266 |
+
background-position: center;
|
| 1267 |
+
transition: background-size 0.5s ease;
|
| 1268 |
+
}
|
| 1269 |
+
|
| 1270 |
+
.ripple-button:active::after {
|
| 1271 |
+
background-size: 200% 200%;
|
| 1272 |
+
}
|
| 1273 |
+
|
| 1274 |
+
/* Aurora background effect */
|
| 1275 |
+
.aurora-bg {
|
| 1276 |
+
position: relative;
|
| 1277 |
+
overflow: hidden;
|
| 1278 |
+
}
|
| 1279 |
+
|
| 1280 |
+
.aurora-bg::before {
|
| 1281 |
+
content: '';
|
| 1282 |
+
position: absolute;
|
| 1283 |
+
top: -50%;
|
| 1284 |
+
left: -50%;
|
| 1285 |
+
width: 200%;
|
| 1286 |
+
height: 200%;
|
| 1287 |
+
background:
|
| 1288 |
+
conic-gradient(from 0deg at 50% 50%, hsla(248, 73%, 65%, 0.3) 0deg, transparent 60deg),
|
| 1289 |
+
conic-gradient(from 120deg at 50% 50%, hsla(280, 65%, 60%, 0.2) 0deg, transparent 60deg),
|
| 1290 |
+
conic-gradient(from 240deg at 50% 50%, hsla(199, 89%, 48%, 0.2) 0deg, transparent 60deg);
|
| 1291 |
+
animation: aurora-rotate 30s linear infinite;
|
| 1292 |
+
opacity: 0.5;
|
| 1293 |
+
}
|
| 1294 |
+
|
| 1295 |
+
@keyframes aurora-rotate {
|
| 1296 |
+
to {
|
| 1297 |
+
transform: rotate(360deg);
|
| 1298 |
+
}
|
| 1299 |
+
}
|
| 1300 |
+
|
| 1301 |
+
/* Scroll indicator */
|
| 1302 |
+
.scroll-indicator {
|
| 1303 |
+
width: 30px;
|
| 1304 |
+
height: 50px;
|
| 1305 |
+
border: 2px solid hsl(var(--primary) / 0.5);
|
| 1306 |
+
border-radius: 25px;
|
| 1307 |
+
position: relative;
|
| 1308 |
+
}
|
| 1309 |
+
|
| 1310 |
+
.scroll-indicator::before {
|
| 1311 |
+
content: '';
|
| 1312 |
+
position: absolute;
|
| 1313 |
+
top: 8px;
|
| 1314 |
+
left: 50%;
|
| 1315 |
+
width: 6px;
|
| 1316 |
+
height: 6px;
|
| 1317 |
+
background: hsl(var(--primary));
|
| 1318 |
+
border-radius: 50%;
|
| 1319 |
+
transform: translateX(-50%);
|
| 1320 |
+
animation: scroll-bounce 2s ease-in-out infinite;
|
| 1321 |
+
}
|
| 1322 |
+
|
| 1323 |
+
@keyframes scroll-bounce {
|
| 1324 |
+
0%, 100% { top: 8px; opacity: 1; }
|
| 1325 |
+
50% { top: 32px; opacity: 0.3; }
|
| 1326 |
+
}
|
| 1327 |
+
|
| 1328 |
+
/* Reveal animation on scroll */
|
| 1329 |
+
.reveal {
|
| 1330 |
+
opacity: 0;
|
| 1331 |
+
transform: translateY(30px);
|
| 1332 |
+
transition: all 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
| 1333 |
+
}
|
| 1334 |
+
|
| 1335 |
+
.reveal.visible {
|
| 1336 |
+
opacity: 1;
|
| 1337 |
+
transform: translateY(0);
|
| 1338 |
+
}
|
| 1339 |
+
|
| 1340 |
+
.reveal-left {
|
| 1341 |
+
opacity: 0;
|
| 1342 |
+
transform: translateX(-50px);
|
| 1343 |
+
transition: all 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
| 1344 |
+
}
|
| 1345 |
+
|
| 1346 |
+
.reveal-left.visible {
|
| 1347 |
+
opacity: 1;
|
| 1348 |
+
transform: translateX(0);
|
| 1349 |
+
}
|
| 1350 |
+
|
| 1351 |
+
.reveal-right {
|
| 1352 |
+
opacity: 0;
|
| 1353 |
+
transform: translateX(50px);
|
| 1354 |
+
transition: all 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
| 1355 |
+
}
|
| 1356 |
+
|
| 1357 |
+
.reveal-right.visible {
|
| 1358 |
+
opacity: 1;
|
| 1359 |
+
transform: translateX(0);
|
| 1360 |
+
}
|
| 1361 |
+
|
| 1362 |
+
.reveal-scale {
|
| 1363 |
+
opacity: 0;
|
| 1364 |
+
transform: scale(0.9);
|
| 1365 |
+
transition: all 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
| 1366 |
+
}
|
| 1367 |
+
|
| 1368 |
+
.reveal-scale.visible {
|
| 1369 |
+
opacity: 1;
|
| 1370 |
+
transform: scale(1);
|
| 1371 |
+
}
|
| 1372 |
+
|
| 1373 |
+
/* Interactive underline */
|
| 1374 |
+
.interactive-underline {
|
| 1375 |
+
position: relative;
|
| 1376 |
+
display: inline-block;
|
| 1377 |
+
}
|
| 1378 |
+
|
| 1379 |
+
.interactive-underline::after {
|
| 1380 |
+
content: '';
|
| 1381 |
+
position: absolute;
|
| 1382 |
+
bottom: -4px;
|
| 1383 |
+
left: 0;
|
| 1384 |
+
width: 0;
|
| 1385 |
+
height: 3px;
|
| 1386 |
+
background: linear-gradient(90deg, hsl(var(--primary)), hsl(var(--accent)));
|
| 1387 |
+
border-radius: 2px;
|
| 1388 |
+
transition: width 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
| 1389 |
+
}
|
| 1390 |
+
|
| 1391 |
+
.interactive-underline:hover::after {
|
| 1392 |
+
width: 100%;
|
| 1393 |
+
}
|
| 1394 |
+
|
| 1395 |
+
/* Counter animation */
|
| 1396 |
+
.counter-animate {
|
| 1397 |
+
display: inline-block;
|
| 1398 |
+
animation: counter-pop 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
| 1399 |
+
}
|
| 1400 |
+
|
| 1401 |
+
@keyframes counter-pop {
|
| 1402 |
+
0% { transform: scale(1); }
|
| 1403 |
+
50% { transform: scale(1.2); }
|
| 1404 |
+
100% { transform: scale(1); }
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
/* Shine effect */
|
| 1408 |
+
.shine-effect {
|
| 1409 |
+
position: relative;
|
| 1410 |
+
overflow: hidden;
|
| 1411 |
+
}
|
| 1412 |
+
|
| 1413 |
+
.shine-effect::after {
|
| 1414 |
+
content: '';
|
| 1415 |
+
position: absolute;
|
| 1416 |
+
top: 0;
|
| 1417 |
+
left: -100%;
|
| 1418 |
+
width: 50%;
|
| 1419 |
+
height: 100%;
|
| 1420 |
+
background: linear-gradient(
|
| 1421 |
+
90deg,
|
| 1422 |
+
transparent,
|
| 1423 |
+
hsl(var(--primary-foreground) / 0.2),
|
| 1424 |
+
transparent
|
| 1425 |
+
);
|
| 1426 |
+
transform: skewX(-25deg);
|
| 1427 |
+
transition: left 0.5s ease;
|
| 1428 |
+
}
|
| 1429 |
+
|
| 1430 |
+
.shine-effect:hover::after {
|
| 1431 |
+
left: 150%;
|
| 1432 |
+
}
|
| 1433 |
+
|
| 1434 |
+
/* Orbit animation */
|
| 1435 |
+
.orbit {
|
| 1436 |
+
animation: orbit-spin 20s linear infinite;
|
| 1437 |
+
}
|
| 1438 |
+
|
| 1439 |
+
@keyframes orbit-spin {
|
| 1440 |
+
from { transform: rotate(0deg) translateX(100px) rotate(0deg); }
|
| 1441 |
+
to { transform: rotate(360deg) translateX(100px) rotate(-360deg); }
|
| 1442 |
+
}
|
| 1443 |
+
|
| 1444 |
+
/* Wave text effect */
|
| 1445 |
+
.wave-text span {
|
| 1446 |
+
display: inline-block;
|
| 1447 |
+
animation: wave 1s ease-in-out infinite;
|
| 1448 |
+
}
|
| 1449 |
+
|
| 1450 |
+
.wave-text span:nth-child(1) { animation-delay: 0s; }
|
| 1451 |
+
.wave-text span:nth-child(2) { animation-delay: 0.1s; }
|
| 1452 |
+
.wave-text span:nth-child(3) { animation-delay: 0.2s; }
|
| 1453 |
+
.wave-text span:nth-child(4) { animation-delay: 0.3s; }
|
| 1454 |
+
.wave-text span:nth-child(5) { animation-delay: 0.4s; }
|
| 1455 |
+
.wave-text span:nth-child(6) { animation-delay: 0.5s; }
|
| 1456 |
+
|
| 1457 |
+
@keyframes wave {
|
| 1458 |
+
0%, 100% { transform: translateY(0); }
|
| 1459 |
+
50% { transform: translateY(-10px); }
|
| 1460 |
+
}
|
| 1461 |
+
|
| 1462 |
+
/* Dark mode specific enhancements */
|
| 1463 |
+
.dark .glass-card {
|
| 1464 |
+
background: hsl(var(--background) / 0.4);
|
| 1465 |
+
border: 1px solid hsl(var(--border) / 0.2);
|
| 1466 |
+
}
|
| 1467 |
+
|
| 1468 |
+
.dark .gradient-mesh {
|
| 1469 |
+
background:
|
| 1470 |
+
radial-gradient(at 40% 20%, hsla(248, 73%, 70%, 0.15) 0px, transparent 50%),
|
| 1471 |
+
radial-gradient(at 80% 0%, hsla(280, 55%, 55%, 0.1) 0px, transparent 50%),
|
| 1472 |
+
radial-gradient(at 0% 50%, hsla(199, 89%, 48%, 0.1) 0px, transparent 50%),
|
| 1473 |
+
radial-gradient(at 80% 50%, hsla(340, 65%, 50%, 0.08) 0px, transparent 50%);
|
| 1474 |
+
}
|
| 1475 |
+
|
| 1476 |
+
/* Underline animation */
|
| 1477 |
+
.underline-animate {
|
| 1478 |
+
position: relative;
|
| 1479 |
+
}
|
| 1480 |
+
|
| 1481 |
+
.underline-animate::after {
|
| 1482 |
+
content: '';
|
| 1483 |
+
position: absolute;
|
| 1484 |
+
bottom: -2px;
|
| 1485 |
+
left: 0;
|
| 1486 |
+
width: 0;
|
| 1487 |
+
height: 2px;
|
| 1488 |
+
background: hsl(var(--primary));
|
| 1489 |
+
transition: width 0.3s ease;
|
| 1490 |
+
}
|
| 1491 |
+
|
| 1492 |
+
.underline-animate:hover::after {
|
| 1493 |
+
width: 100%;
|
| 1494 |
+
}
|
app/layout.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Inter, JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const inter = Inter({
|
| 6 |
+
variable: "--font-sans",
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
display: "swap",
|
| 9 |
+
preload: true,
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
const jetbrainsMono = JetBrains_Mono({
|
| 13 |
+
variable: "--font-mono",
|
| 14 |
+
subsets: ["latin"],
|
| 15 |
+
display: "swap",
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
const jakarta = Plus_Jakarta_Sans({
|
| 19 |
+
variable: "--font-heading",
|
| 20 |
+
subsets: ["latin"],
|
| 21 |
+
display: "swap",
|
| 22 |
+
preload: true,
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
export const metadata: Metadata = {
|
| 26 |
+
title: {
|
| 27 |
+
default: "NexaFlow — Digital Agency | Web & App Development",
|
| 28 |
+
template: "%s | NexaFlow",
|
| 29 |
+
},
|
| 30 |
+
description: "We design and build high-performance websites, mobile apps, and digital products that drive real business growth. Let's create something extraordinary.",
|
| 31 |
+
keywords: ["web development", "app development", "UI/UX design", "SEO", "digital agency", "nexaflow"],
|
| 32 |
+
authors: [{ name: "NexaFlow" }],
|
| 33 |
+
creator: "NexaFlow",
|
| 34 |
+
openGraph: {
|
| 35 |
+
type: "website",
|
| 36 |
+
locale: "en_US",
|
| 37 |
+
url: "https://nexaflow.dev",
|
| 38 |
+
siteName: "NexaFlow",
|
| 39 |
+
title: "NexaFlow — Digital Agency | Web & App Development",
|
| 40 |
+
description: "We design and build high-performance websites, mobile apps, and digital products that drive real business growth.",
|
| 41 |
+
},
|
| 42 |
+
twitter: {
|
| 43 |
+
card: "summary_large_image",
|
| 44 |
+
title: "NexaFlow — Digital Agency",
|
| 45 |
+
description: "We design and build high-performance websites, mobile apps, and digital products.",
|
| 46 |
+
},
|
| 47 |
+
robots: {
|
| 48 |
+
index: true,
|
| 49 |
+
follow: true,
|
| 50 |
+
},
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
import { SessionProvider } from "next-auth/react";
|
| 54 |
+
import { ThemeProvider } from "@/components/theme-provider";
|
| 55 |
+
import { Toaster } from "@/components/ui/sonner";
|
| 56 |
+
|
| 57 |
+
export default function RootLayout({
|
| 58 |
+
children,
|
| 59 |
+
}: Readonly<{
|
| 60 |
+
children: React.ReactNode;
|
| 61 |
+
}>) {
|
| 62 |
+
return (
|
| 63 |
+
<html lang="en" suppressHydrationWarning>
|
| 64 |
+
<body
|
| 65 |
+
className={`${inter.variable} ${jetbrainsMono.variable} ${jakarta.variable} font-sans antialiased`}
|
| 66 |
+
>
|
| 67 |
+
<ThemeProvider
|
| 68 |
+
attribute="class"
|
| 69 |
+
defaultTheme="system"
|
| 70 |
+
enableSystem
|
| 71 |
+
disableTransitionOnChange
|
| 72 |
+
>
|
| 73 |
+
<SessionProvider>
|
| 74 |
+
{children}
|
| 75 |
+
<Toaster />
|
| 76 |
+
</SessionProvider>
|
| 77 |
+
</ThemeProvider>
|
| 78 |
+
</body>
|
| 79 |
+
</html>
|
| 80 |
+
);
|
| 81 |
+
}
|
app/unauthorized/page.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from "next/link"
|
| 2 |
+
import { Button } from "@/components/ui/button"
|
| 3 |
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
| 4 |
+
|
| 5 |
+
export default function UnauthorizedPage() {
|
| 6 |
+
return (
|
| 7 |
+
<div className="flex min-h-screen items-center justify-center bg-muted/50 p-4">
|
| 8 |
+
<Card className="w-full max-w-md border-destructive/50">
|
| 9 |
+
<CardHeader>
|
| 10 |
+
<CardTitle className="text-2xl font-bold text-destructive">Access Denied</CardTitle>
|
| 11 |
+
<CardDescription>
|
| 12 |
+
You do not have permission to view this page.
|
| 13 |
+
</CardDescription>
|
| 14 |
+
</CardHeader>
|
| 15 |
+
<CardContent>
|
| 16 |
+
<p className="text-sm text-muted-foreground">
|
| 17 |
+
Please contact your administrator if you believe this is an error.
|
| 18 |
+
</p>
|
| 19 |
+
</CardContent>
|
| 20 |
+
<CardFooter className="flex justify-center gap-4">
|
| 21 |
+
<Button asChild variant="outline">
|
| 22 |
+
<Link href="/admin/dashboard">Return to Dashboard</Link>
|
| 23 |
+
</Button>
|
| 24 |
+
<Button asChild variant="default">
|
| 25 |
+
<Link href="/admin/login">Login with different account</Link>
|
| 26 |
+
</Button>
|
| 27 |
+
</CardFooter>
|
| 28 |
+
</Card>
|
| 29 |
+
</div>
|
| 30 |
+
)
|
| 31 |
+
}
|
components.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "",
|
| 8 |
+
"css": "app/globals.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"iconLibrary": "lucide",
|
| 14 |
+
"rtl": false,
|
| 15 |
+
"aliases": {
|
| 16 |
+
"components": "@/components",
|
| 17 |
+
"utils": "@/lib/utils",
|
| 18 |
+
"ui": "@/components/ui",
|
| 19 |
+
"lib": "@/lib",
|
| 20 |
+
"hooks": "@/hooks"
|
| 21 |
+
},
|
| 22 |
+
"registries": {}
|
| 23 |
+
}
|
components/admin/BlogForm.tsx
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
import { useSession } from "next-auth/react";
|
| 6 |
+
import { useEditor, EditorContent } from '@tiptap/react';
|
| 7 |
+
import StarterKit from '@tiptap/starter-kit';
|
| 8 |
+
import LinkExtension from '@tiptap/extension-link';
|
| 9 |
+
import ImageExtension from '@tiptap/extension-image';
|
| 10 |
+
import { Button } from '@/components/ui/button';
|
| 11 |
+
import { Input } from '@/components/ui/input';
|
| 12 |
+
import { Label } from '@/components/ui/label';
|
| 13 |
+
import { Textarea } from '@/components/ui/textarea';
|
| 14 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
| 15 |
+
import { Card, CardContent } from '@/components/ui/card';
|
| 16 |
+
import { Bold, Italic, List, ListOrdered, Link as LinkIcon, Image as ImageIcon, Loader2 } from 'lucide-react';
|
| 17 |
+
|
| 18 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api';
|
| 19 |
+
|
| 20 |
+
interface BlogFormProps {
|
| 21 |
+
initialData?: any;
|
| 22 |
+
isEditing?: boolean;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export default function BlogForm({ initialData = null, isEditing = false }: BlogFormProps) {
|
| 26 |
+
const router = useRouter();
|
| 27 |
+
const { data: session } = useSession();
|
| 28 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 29 |
+
|
| 30 |
+
// Form State
|
| 31 |
+
const [title, setTitle] = useState(initialData?.title || '');
|
| 32 |
+
const [slug, setSlug] = useState(initialData?.slug || '');
|
| 33 |
+
const [excerpt, setExcerpt] = useState(initialData?.excerpt || '');
|
| 34 |
+
const [featuredImage, setFeaturedImage] = useState(initialData?.featuredImage || '');
|
| 35 |
+
const [status, setStatus] = useState(initialData?.status || 'DRAFT');
|
| 36 |
+
const [publishedAt, setPublishedAt] = useState(initialData?.publishedAt ? new Date(initialData.publishedAt).toISOString().slice(0, 16) : '');
|
| 37 |
+
const [tags, setTags] = useState(initialData?.tags ? JSON.parse(initialData.tags).join(', ') : '');
|
| 38 |
+
|
| 39 |
+
const editor = useEditor({
|
| 40 |
+
extensions: [
|
| 41 |
+
StarterKit,
|
| 42 |
+
LinkExtension.configure({ openOnClick: false }),
|
| 43 |
+
ImageExtension.configure({ inline: true }),
|
| 44 |
+
],
|
| 45 |
+
content: initialData?.content || '<p>Start writing your post here...</p>',
|
| 46 |
+
immediatelyRender: false,
|
| 47 |
+
editorProps: {
|
| 48 |
+
attributes: {
|
| 49 |
+
class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none min-h-[400px] p-4 border rounded-md max-w-none',
|
| 50 |
+
},
|
| 51 |
+
},
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
const setLink = () => {
|
| 55 |
+
const previousUrl = editor?.getAttributes('link').href;
|
| 56 |
+
const url = window.prompt('URL', previousUrl);
|
| 57 |
+
if (url === null) return;
|
| 58 |
+
if (url === '') {
|
| 59 |
+
editor?.chain().focus().extendMarkRange('link').unsetLink().run();
|
| 60 |
+
return;
|
| 61 |
+
}
|
| 62 |
+
editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const addImage = () => {
|
| 66 |
+
const url = window.prompt('Image URL');
|
| 67 |
+
if (url) {
|
| 68 |
+
editor?.chain().focus().setImage({ src: url }).run();
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 73 |
+
e.preventDefault();
|
| 74 |
+
setIsLoading(true);
|
| 75 |
+
|
| 76 |
+
const content = editor?.getHTML();
|
| 77 |
+
const tagsArray = tags.split(',').map((t: string) => t.trim()).filter(Boolean);
|
| 78 |
+
|
| 79 |
+
const payload = {
|
| 80 |
+
title,
|
| 81 |
+
slug: slug || undefined,
|
| 82 |
+
content,
|
| 83 |
+
excerpt,
|
| 84 |
+
featuredImage,
|
| 85 |
+
status,
|
| 86 |
+
publishedAt: publishedAt ? new Date(publishedAt).toISOString() : null,
|
| 87 |
+
tags: JSON.stringify(tagsArray)
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
try {
|
| 91 |
+
const url = isEditing && initialData?.id
|
| 92 |
+
? `${API_URL}/admin/blog/${initialData.id}`
|
| 93 |
+
: `${API_URL}/admin/blog`;
|
| 94 |
+
|
| 95 |
+
const method = isEditing ? 'PUT' : 'POST';
|
| 96 |
+
|
| 97 |
+
const res = await fetch(url, {
|
| 98 |
+
method,
|
| 99 |
+
headers: {
|
| 100 |
+
'Content-Type': 'application/json',
|
| 101 |
+
'Authorization': `Bearer ${session?.user?.id}`
|
| 102 |
+
},
|
| 103 |
+
body: JSON.stringify(payload)
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
if (res.ok) {
|
| 107 |
+
router.push('/admin/blog');
|
| 108 |
+
router.refresh();
|
| 109 |
+
} else {
|
| 110 |
+
const data = await res.json();
|
| 111 |
+
alert(`Error: ${data.error || 'Failed to save post'}`);
|
| 112 |
+
}
|
| 113 |
+
} catch (error) {
|
| 114 |
+
console.error("Save error:", error);
|
| 115 |
+
alert("An unexpected error occurred while saving.");
|
| 116 |
+
} finally {
|
| 117 |
+
setIsLoading(false);
|
| 118 |
+
}
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
return (
|
| 122 |
+
<form onSubmit={handleSubmit} className="space-y-8">
|
| 123 |
+
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
| 124 |
+
<h2 className="text-3xl font-bold tracking-tight">
|
| 125 |
+
{isEditing ? 'Edit Article' : 'New Article'}
|
| 126 |
+
</h2>
|
| 127 |
+
<div className="flex gap-2 w-full md:w-auto">
|
| 128 |
+
<Button variant="outline" type="button" onClick={() => router.push('/admin/blog')}>
|
| 129 |
+
Cancel
|
| 130 |
+
</Button>
|
| 131 |
+
<Button type="submit" disabled={isLoading || !title || !editor?.getHTML()}>
|
| 132 |
+
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
| 133 |
+
{isEditing ? 'Save Changes' : 'Create Post'}
|
| 134 |
+
</Button>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
| 139 |
+
{/* Main Content Area */}
|
| 140 |
+
<div className="lg:col-span-2 space-y-6">
|
| 141 |
+
<Card>
|
| 142 |
+
<CardContent className="p-6 space-y-4">
|
| 143 |
+
<div className="space-y-2">
|
| 144 |
+
<Label htmlFor="title">Post Title *</Label>
|
| 145 |
+
<Input
|
| 146 |
+
id="title"
|
| 147 |
+
value={title}
|
| 148 |
+
onChange={(e) => setTitle(e.target.value)}
|
| 149 |
+
placeholder="Enter an engaging title..."
|
| 150 |
+
required
|
| 151 |
+
className="text-lg font-medium"
|
| 152 |
+
/>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<div className="space-y-2">
|
| 156 |
+
<Label>Content *</Label>
|
| 157 |
+
{editor && (
|
| 158 |
+
<div className="border rounded-md overflow-hidden bg-background">
|
| 159 |
+
{/* TipTap Toolbar */}
|
| 160 |
+
<div className="flex flex-wrap items-center gap-1 p-1 border-b bg-muted/30">
|
| 161 |
+
<Button type="button" variant="ghost" size="sm" onClick={() => editor.chain().focus().toggleBold().run()} className={editor.isActive('bold') ? 'bg-muted' : ''}>
|
| 162 |
+
<Bold className="h-4 w-4" />
|
| 163 |
+
</Button>
|
| 164 |
+
<Button type="button" variant="ghost" size="sm" onClick={() => editor.chain().focus().toggleItalic().run()} className={editor.isActive('italic') ? 'bg-muted' : ''}>
|
| 165 |
+
<Italic className="h-4 w-4" />
|
| 166 |
+
</Button>
|
| 167 |
+
<div className="w-px h-4 bg-border mx-1" />
|
| 168 |
+
<Button type="button" variant="ghost" size="sm" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={editor.isActive('heading', { level: 2 }) ? 'bg-muted' : ''}>
|
| 169 |
+
H2
|
| 170 |
+
</Button>
|
| 171 |
+
<Button type="button" variant="ghost" size="sm" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={editor.isActive('heading', { level: 3 }) ? 'bg-muted' : ''}>
|
| 172 |
+
H3
|
| 173 |
+
</Button>
|
| 174 |
+
<div className="w-px h-4 bg-border mx-1" />
|
| 175 |
+
<Button type="button" variant="ghost" size="sm" onClick={() => editor.chain().focus().toggleBulletList().run()} className={editor.isActive('bulletList') ? 'bg-muted' : ''}>
|
| 176 |
+
<List className="h-4 w-4" />
|
| 177 |
+
</Button>
|
| 178 |
+
<Button type="button" variant="ghost" size="sm" onClick={() => editor.chain().focus().toggleOrderedList().run()} className={editor.isActive('orderedList') ? 'bg-muted' : ''}>
|
| 179 |
+
<ListOrdered className="h-4 w-4" />
|
| 180 |
+
</Button>
|
| 181 |
+
<div className="w-px h-4 bg-border mx-1" />
|
| 182 |
+
<Button type="button" variant="ghost" size="sm" onClick={setLink} className={editor.isActive('link') ? 'bg-muted' : ''}>
|
| 183 |
+
<LinkIcon className="h-4 w-4" />
|
| 184 |
+
</Button>
|
| 185 |
+
<Button type="button" variant="ghost" size="sm" onClick={addImage}>
|
| 186 |
+
<ImageIcon className="h-4 w-4" />
|
| 187 |
+
</Button>
|
| 188 |
+
</div>
|
| 189 |
+
{/* TipTap Editor */}
|
| 190 |
+
<EditorContent editor={editor} />
|
| 191 |
+
</div>
|
| 192 |
+
)}
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
<div className="space-y-2">
|
| 196 |
+
<Label htmlFor="excerpt">Excerpt / Summary</Label>
|
| 197 |
+
<Textarea
|
| 198 |
+
id="excerpt"
|
| 199 |
+
value={excerpt}
|
| 200 |
+
onChange={(e) => setExcerpt(e.target.value)}
|
| 201 |
+
placeholder="Brief summary for search engines and social sharing..."
|
| 202 |
+
rows={3}
|
| 203 |
+
/>
|
| 204 |
+
</div>
|
| 205 |
+
</CardContent>
|
| 206 |
+
</Card>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
{/* Sidebar Metrics & Settings */}
|
| 210 |
+
<div className="space-y-6">
|
| 211 |
+
<Card>
|
| 212 |
+
<CardContent className="p-6 space-y-4">
|
| 213 |
+
<div className="space-y-2">
|
| 214 |
+
<Label htmlFor="status">Publishing Status</Label>
|
| 215 |
+
<Select value={status} onValueChange={setStatus}>
|
| 216 |
+
<SelectTrigger>
|
| 217 |
+
<SelectValue placeholder="Select status" />
|
| 218 |
+
</SelectTrigger>
|
| 219 |
+
<SelectContent>
|
| 220 |
+
<SelectItem value="DRAFT">Draft</SelectItem>
|
| 221 |
+
<SelectItem value="PUBLISHED">Published</SelectItem>
|
| 222 |
+
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
|
| 223 |
+
</SelectContent>
|
| 224 |
+
</Select>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
<div className="space-y-2">
|
| 228 |
+
<Label htmlFor="publishedAt">Publish Date</Label>
|
| 229 |
+
<Input
|
| 230 |
+
id="publishedAt"
|
| 231 |
+
type="datetime-local"
|
| 232 |
+
value={publishedAt}
|
| 233 |
+
onChange={(e) => setPublishedAt(e.target.value)}
|
| 234 |
+
disabled={status === 'DRAFT'}
|
| 235 |
+
/>
|
| 236 |
+
{status === 'DRAFT' && <p className="text-xs text-muted-foreground">Date is disabled while in Draft status.</p>}
|
| 237 |
+
</div>
|
| 238 |
+
|
| 239 |
+
<div className="space-y-2 pt-4 border-t">
|
| 240 |
+
<Label htmlFor="slug">Custom URL Slug (Optional)</Label>
|
| 241 |
+
<Input
|
| 242 |
+
id="slug"
|
| 243 |
+
value={slug}
|
| 244 |
+
onChange={(e) => setSlug(e.target.value)}
|
| 245 |
+
placeholder="e.g. my-awesome-post"
|
| 246 |
+
/>
|
| 247 |
+
<p className="text-xs text-muted-foreground">Leave blank to auto-generate from title.</p>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
<div className="space-y-2 pt-4 border-t">
|
| 251 |
+
<Label htmlFor="featuredImage">Featured Image URL</Label>
|
| 252 |
+
<Input
|
| 253 |
+
id="featuredImage"
|
| 254 |
+
value={featuredImage}
|
| 255 |
+
onChange={(e) => setFeaturedImage(e.target.value)}
|
| 256 |
+
placeholder="https://..."
|
| 257 |
+
/>
|
| 258 |
+
{featuredImage && (
|
| 259 |
+
<div className="mt-2 text-xs text-muted-foreground overflow-hidden rounded-md h-32 bg-muted relative">
|
| 260 |
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
| 261 |
+
<img src={featuredImage} alt="Featured preview" className="object-cover w-full h-full" />
|
| 262 |
+
</div>
|
| 263 |
+
)}
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
<div className="space-y-2 pt-4 border-t">
|
| 267 |
+
<Label htmlFor="tags">Tags (Comma separated)</Label>
|
| 268 |
+
<Input
|
| 269 |
+
id="tags"
|
| 270 |
+
value={tags}
|
| 271 |
+
onChange={(e) => setTags(e.target.value)}
|
| 272 |
+
placeholder="React, Design, Tech"
|
| 273 |
+
/>
|
| 274 |
+
</div>
|
| 275 |
+
</CardContent>
|
| 276 |
+
</Card>
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
</form>
|
| 280 |
+
);
|
| 281 |
+
}
|
components/auth/RoleGuard.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useSession } from "next-auth/react"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import { useEffect } from "react"
|
| 6 |
+
import { RoleType } from "@/types/auth"
|
| 7 |
+
|
| 8 |
+
interface RoleGuardProps {
|
| 9 |
+
children: React.ReactNode
|
| 10 |
+
allowedRoles: RoleType[]
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function RoleGuard({ children, allowedRoles }: RoleGuardProps) {
|
| 14 |
+
const { data: session, status } = useSession()
|
| 15 |
+
const router = useRouter()
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
if (status === "loading") return
|
| 19 |
+
|
| 20 |
+
if (!session) {
|
| 21 |
+
router.push("/admin/login")
|
| 22 |
+
return
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const userRole = (session.user as any).role as RoleType
|
| 26 |
+
|
| 27 |
+
if (!allowedRoles.includes(userRole)) {
|
| 28 |
+
router.push("/unauthorized")
|
| 29 |
+
}
|
| 30 |
+
}, [session, status, router, allowedRoles])
|
| 31 |
+
|
| 32 |
+
if (status === "loading") {
|
| 33 |
+
return <div>Loading...</div>
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if (!session) {
|
| 37 |
+
return null
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const userRole = (session.user as any).role as RoleType
|
| 41 |
+
if (!allowedRoles.includes(userRole)) {
|
| 42 |
+
return null
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
return <>{children}</>
|
| 46 |
+
}
|
components/home/CTASection.tsx
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import Link from "next/link"
|
| 4 |
+
import { Button } from "@/components/ui/button"
|
| 5 |
+
import { ArrowRight, Sparkles, Zap, Rocket, MessageCircle } from "lucide-react"
|
| 6 |
+
import { useRef, useState, useEffect } from "react"
|
| 7 |
+
|
| 8 |
+
// Pre-computed particle data to avoid hydration mismatch
|
| 9 |
+
const CTA_PARTICLES = [
|
| 10 |
+
// Left side particles
|
| 11 |
+
{ left: 3.5, top: 15.2, delay: 2.1, duration: 19.4 },
|
| 12 |
+
{ left: 8.7, top: 45.8, delay: 7.6, duration: 21.8 },
|
| 13 |
+
{ left: 5.2, top: 78.3, delay: 11.2, duration: 18.2 },
|
| 14 |
+
{ left: 12.4, top: 32.5, delay: 4.8, duration: 22.6 },
|
| 15 |
+
{ left: 6.8, top: 62.9, delay: 9.3, duration: 17.5 },
|
| 16 |
+
|
| 17 |
+
// Right side particles
|
| 18 |
+
{ left: 91.3, top: 18.6, delay: 5.4, duration: 20.3 },
|
| 19 |
+
{ left: 86.5, top: 52.4, delay: 10.1, duration: 18.9 },
|
| 20 |
+
{ left: 94.7, top: 85.2, delay: 3.7, duration: 22.1 },
|
| 21 |
+
{ left: 88.2, top: 38.7, delay: 8.4, duration: 19.6 },
|
| 22 |
+
{ left: 92.8, top: 68.4, delay: 12.9, duration: 21.2 },
|
| 23 |
+
|
| 24 |
+
// Top edge
|
| 25 |
+
{ left: 25.4, top: 5.8, delay: 6.2, duration: 18.4 },
|
| 26 |
+
{ left: 55.7, top: 3.2, delay: 1.5, duration: 22.8 },
|
| 27 |
+
{ left: 72.1, top: 8.4, delay: 9.8, duration: 17.9 },
|
| 28 |
+
|
| 29 |
+
// Bottom edge
|
| 30 |
+
{ left: 18.9, top: 92.6, delay: 4.3, duration: 20.7 },
|
| 31 |
+
{ left: 48.3, top: 95.1, delay: 11.7, duration: 19.2 },
|
| 32 |
+
{ left: 68.5, top: 89.3, delay: 7.1, duration: 21.5 },
|
| 33 |
+
|
| 34 |
+
// Corners
|
| 35 |
+
{ left: 2.1, top: 2.4, delay: 13.5, duration: 18.6 },
|
| 36 |
+
{ left: 97.8, top: 4.7, delay: 0.8, duration: 22.4 },
|
| 37 |
+
{ left: 1.5, top: 96.2, delay: 5.6, duration: 19.8 },
|
| 38 |
+
{ left: 98.4, top: 94.5, delay: 10.4, duration: 17.3 },
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
export function CTASection() {
|
| 42 |
+
const sectionRef = useRef<HTMLElement>(null)
|
| 43 |
+
const [isVisible, setIsVisible] = useState(false)
|
| 44 |
+
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
|
| 45 |
+
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
const observer = new IntersectionObserver(
|
| 48 |
+
([entry]) => {
|
| 49 |
+
if (entry.isIntersecting) {
|
| 50 |
+
setIsVisible(true)
|
| 51 |
+
}
|
| 52 |
+
},
|
| 53 |
+
{ threshold: 0.2 }
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
if (sectionRef.current) {
|
| 57 |
+
observer.observe(sectionRef.current)
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
return () => observer.disconnect()
|
| 61 |
+
}, [])
|
| 62 |
+
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
const handleMouseMove = (e: MouseEvent) => {
|
| 65 |
+
if (sectionRef.current) {
|
| 66 |
+
const rect = sectionRef.current.getBoundingClientRect()
|
| 67 |
+
setMousePosition({
|
| 68 |
+
x: (e.clientX - rect.left) / rect.width,
|
| 69 |
+
y: (e.clientY - rect.top) / rect.height,
|
| 70 |
+
})
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
window.addEventListener('mousemove', handleMouseMove)
|
| 75 |
+
return () => window.removeEventListener('mousemove', handleMouseMove)
|
| 76 |
+
}, [])
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<section ref={sectionRef} className="relative py-24 overflow-hidden bg-background" aria-label="Call to action">
|
| 80 |
+
{/* Dynamic gradient mesh */}
|
| 81 |
+
<div className="absolute inset-0 gradient-mesh opacity-30" />
|
| 82 |
+
|
| 83 |
+
{/* Animated orbs */}
|
| 84 |
+
<div
|
| 85 |
+
className="absolute w-[600px] h-[600px] rounded-full opacity-20 blur-3xl pointer-events-none transition-transform duration-1000"
|
| 86 |
+
style={{
|
| 87 |
+
background: 'radial-gradient(circle, hsl(var(--primary)) 0%, transparent 70%)',
|
| 88 |
+
top: '-20%',
|
| 89 |
+
right: '-10%',
|
| 90 |
+
transform: `translate(${mousePosition.x * 50}px, ${mousePosition.y * 50}px)`,
|
| 91 |
+
}}
|
| 92 |
+
/>
|
| 93 |
+
<div
|
| 94 |
+
className="absolute w-[500px] h-[500px] rounded-full opacity-15 blur-3xl pointer-events-none transition-transform duration-1000"
|
| 95 |
+
style={{
|
| 96 |
+
background: 'radial-gradient(circle, hsl(280, 65%, 60%) 0%, transparent 70%)',
|
| 97 |
+
bottom: '-20%',
|
| 98 |
+
left: '-10%',
|
| 99 |
+
transform: `translate(${-mousePosition.x * 30}px, ${-mousePosition.y * 30}px)`,
|
| 100 |
+
}}
|
| 101 |
+
/>
|
| 102 |
+
|
| 103 |
+
{/* Animated particles */}
|
| 104 |
+
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
| 105 |
+
{CTA_PARTICLES.map((particle, i) => (
|
| 106 |
+
<div
|
| 107 |
+
key={i}
|
| 108 |
+
className="particle"
|
| 109 |
+
style={{
|
| 110 |
+
left: `${particle.left}%`,
|
| 111 |
+
top: `${particle.top}%`,
|
| 112 |
+
animationDelay: `${particle.delay}s`,
|
| 113 |
+
animationDuration: `${particle.duration}s`,
|
| 114 |
+
}}
|
| 115 |
+
/>
|
| 116 |
+
))}
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
{/* Grid pattern */}
|
| 120 |
+
<div className="absolute inset-0 opacity-5">
|
| 121 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,hsl(var(--primary))_1px,transparent_1px)] bg-size-[48px_48px]" />
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<div className="container relative z-10 flex flex-col items-center text-center">
|
| 125 |
+
<div
|
| 126 |
+
className={`max-w-4xl transition-all duration-1000 ${
|
| 127 |
+
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-20'
|
| 128 |
+
}`}
|
| 129 |
+
>
|
| 130 |
+
{/* Main CTA card with animated border */}
|
| 131 |
+
<div className="relative">
|
| 132 |
+
{/* Animated gradient border */}
|
| 133 |
+
<div className="absolute -inset-1 rounded-[2rem] animated-border opacity-50" />
|
| 134 |
+
|
| 135 |
+
<div className="relative glass-card rounded-[2rem] p-12 md:p-16 overflow-hidden">
|
| 136 |
+
{/* Inner glow */}
|
| 137 |
+
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 via-transparent to-accent/10 opacity-50" />
|
| 138 |
+
|
| 139 |
+
{/* Floating icons */}
|
| 140 |
+
<div className="absolute top-8 left-8 animate-float opacity-20" style={{ animationDelay: '0s' }}>
|
| 141 |
+
<Rocket className="w-8 h-8 text-primary" suppressHydrationWarning />
|
| 142 |
+
</div>
|
| 143 |
+
<div className="absolute top-12 right-12 animate-float opacity-20" style={{ animationDelay: '1s' }}>
|
| 144 |
+
<Zap className="w-6 h-6 text-amber-500" suppressHydrationWarning />
|
| 145 |
+
</div>
|
| 146 |
+
<div className="absolute bottom-8 left-16 animate-float opacity-20" style={{ animationDelay: '2s' }}>
|
| 147 |
+
<MessageCircle className="w-7 h-7 text-cyan-500" suppressHydrationWarning />
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<div className="relative z-10">
|
| 151 |
+
{/* Badge */}
|
| 152 |
+
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass-card mb-8">
|
| 153 |
+
<Sparkles className="w-4 h-4 text-primary animate-pulse" suppressHydrationWarning />
|
| 154 |
+
<span className="text-sm font-medium">Let's Create Together</span>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<h2 className="text-4xl md:text-5xl lg:text-6xl mb-8 text-foreground font-bold tracking-tight">
|
| 158 |
+
Ready to Start Your{" "}
|
| 159 |
+
<span className="gradient-text relative">
|
| 160 |
+
Project
|
| 161 |
+
<svg className="absolute -bottom-2 left-0 w-full" viewBox="0 0 200 12" fill="none">
|
| 162 |
+
<path d="M2 10C50 4 150 2 198 10" stroke="hsl(var(--primary))" strokeWidth="3" strokeLinecap="round" style={{
|
| 163 |
+
strokeDasharray: '200',
|
| 164 |
+
strokeDashoffset: isVisible ? '0' : '200',
|
| 165 |
+
transition: 'stroke-dashoffset 1s ease 0.5s'
|
| 166 |
+
}} />
|
| 167 |
+
</svg>
|
| 168 |
+
</span>
|
| 169 |
+
?
|
| 170 |
+
</h2>
|
| 171 |
+
|
| 172 |
+
<p className="text-lg md:text-xl mb-12 max-w-2xl text-muted-foreground leading-relaxed mx-auto">
|
| 173 |
+
Let's build something{" "}
|
| 174 |
+
<span className="text-primary font-medium">amazing</span> together.
|
| 175 |
+
Contact us today for a free consultation and a detailed project quote.
|
| 176 |
+
</p>
|
| 177 |
+
|
| 178 |
+
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
| 179 |
+
<Button
|
| 180 |
+
asChild
|
| 181 |
+
size="xl"
|
| 182 |
+
className="group h-14 px-10 text-lg rounded-2xl shine-effect relative overflow-hidden"
|
| 183 |
+
>
|
| 184 |
+
<Link href="/quote">
|
| 185 |
+
<span className="relative z-10 flex items-center">
|
| 186 |
+
Get a Free Quote
|
| 187 |
+
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1 group-hover:scale-110" suppressHydrationWarning />
|
| 188 |
+
</span>
|
| 189 |
+
</Link>
|
| 190 |
+
</Button>
|
| 191 |
+
<Button
|
| 192 |
+
asChild
|
| 193 |
+
size="xl"
|
| 194 |
+
variant="outline"
|
| 195 |
+
className="group h-14 px-10 text-lg rounded-2xl glass-card hover:bg-primary/10"
|
| 196 |
+
>
|
| 197 |
+
<Link href="/contact">
|
| 198 |
+
<MessageCircle className="mr-2 h-5 w-5 group-hover:rotate-12 transition-transform" suppressHydrationWarning />
|
| 199 |
+
Contact Us
|
| 200 |
+
</Link>
|
| 201 |
+
</Button>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
{/* Trust badges */}
|
| 205 |
+
<div className="flex items-center justify-center gap-6 mt-10 text-sm text-muted-foreground">
|
| 206 |
+
<div className="flex items-center gap-2">
|
| 207 |
+
<Zap className="w-4 h-4 text-amber-500" suppressHydrationWarning />
|
| 208 |
+
<span>Fast Response</span>
|
| 209 |
+
</div>
|
| 210 |
+
<div className="w-1 h-1 rounded-full bg-muted-foreground/50" />
|
| 211 |
+
<div className="flex items-center gap-2">
|
| 212 |
+
<Rocket className="w-4 h-4 text-primary" suppressHydrationWarning />
|
| 213 |
+
<span>Free Consultation</span>
|
| 214 |
+
</div>
|
| 215 |
+
<div className="w-1 h-1 rounded-full bg-muted-foreground/50" />
|
| 216 |
+
<div className="flex items-center gap-2">
|
| 217 |
+
<Sparkles className="w-4 h-4 text-cyan-500" suppressHydrationWarning />
|
| 218 |
+
<span>Custom Solutions</span>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
{/* Shine effect */}
|
| 224 |
+
<div className="absolute inset-0 shine-effect pointer-events-none" />
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
</section>
|
| 230 |
+
)
|
| 231 |
+
}
|
components/home/FeaturedPortfolio.tsx
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import Link from "next/link"
|
| 4 |
+
import { Button } from "@/components/ui/button"
|
| 5 |
+
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
| 6 |
+
import { ExternalLink, Eye, ArrowUpRight } from "lucide-react"
|
| 7 |
+
import { useRef, useState, useEffect } from "react"
|
| 8 |
+
|
| 9 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000/api"
|
| 10 |
+
|
| 11 |
+
interface Project {
|
| 12 |
+
title: string
|
| 13 |
+
slug: string
|
| 14 |
+
category: string
|
| 15 |
+
tags: string[]
|
| 16 |
+
imageUrl: string
|
| 17 |
+
gradient: string
|
| 18 |
+
emoji: string
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
async function getFeaturedProjects(): Promise<Project[]> {
|
| 22 |
+
try {
|
| 23 |
+
const res = await fetch(`${API_URL}/portfolio`, { cache: 'no-store' })
|
| 24 |
+
if (!res.ok) return []
|
| 25 |
+
const data = await res.json()
|
| 26 |
+
|
| 27 |
+
return data.slice(0, 3).map((item: any) => ({
|
| 28 |
+
title: item.title,
|
| 29 |
+
slug: item.slug,
|
| 30 |
+
category: item.industry || "General",
|
| 31 |
+
tags: item.technologies ? item.technologies.split(',').map((t: string) => t.trim()) : [],
|
| 32 |
+
imageUrl: item.imageUrl,
|
| 33 |
+
gradient: "from-blue-600 to-cyan-500",
|
| 34 |
+
emoji: "✨",
|
| 35 |
+
}))
|
| 36 |
+
} catch (e) {
|
| 37 |
+
console.error("Failed to fetch featured projects", e)
|
| 38 |
+
return []
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function PortfolioCard({ project, index }: { project: Project; index: number }) {
|
| 43 |
+
const cardRef = useRef<HTMLDivElement>(null)
|
| 44 |
+
const [isVisible, setIsVisible] = useState(false)
|
| 45 |
+
const [isHovered, setIsHovered] = useState(false)
|
| 46 |
+
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
const observer = new IntersectionObserver(
|
| 49 |
+
([entry]) => {
|
| 50 |
+
if (entry.isIntersecting) {
|
| 51 |
+
setIsVisible(true)
|
| 52 |
+
}
|
| 53 |
+
},
|
| 54 |
+
{ threshold: 0.1 }
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
if (cardRef.current) {
|
| 58 |
+
observer.observe(cardRef.current)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return () => observer.disconnect()
|
| 62 |
+
}, [])
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<article
|
| 66 |
+
ref={cardRef}
|
| 67 |
+
className={`transition-all duration-700 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-20'}`}
|
| 68 |
+
style={{ transitionDelay: `${index * 150}ms` }}
|
| 69 |
+
>
|
| 70 |
+
<Link href={`/portfolio/${project.slug}`} className="block h-full">
|
| 71 |
+
<Card
|
| 72 |
+
className="group relative h-full overflow-hidden glass-card cursor-pointer transition-all duration-500 rounded-3xl border-0"
|
| 73 |
+
onMouseEnter={() => setIsHovered(true)}
|
| 74 |
+
onMouseLeave={() => setIsHovered(false)}
|
| 75 |
+
>
|
| 76 |
+
{/* Animated background gradient */}
|
| 77 |
+
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
| 78 |
+
|
| 79 |
+
{/* Thumbnail with overlay effect */}
|
| 80 |
+
<div className="relative aspect-16/10 overflow-hidden">
|
| 81 |
+
{project.imageUrl ? (
|
| 82 |
+
<img
|
| 83 |
+
src={project.imageUrl.startsWith('http') || project.imageUrl.startsWith('/') ? project.imageUrl : `/${project.imageUrl}`}
|
| 84 |
+
alt={project.title}
|
| 85 |
+
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
| 86 |
+
/>
|
| 87 |
+
) : (
|
| 88 |
+
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary/20 to-accent/20">
|
| 89 |
+
<span className="text-6xl group-hover:scale-110 transition-transform duration-300" role="img" aria-label={project.title}>
|
| 90 |
+
{project.emoji || '🚀'}
|
| 91 |
+
</span>
|
| 92 |
+
</div>
|
| 93 |
+
)}
|
| 94 |
+
|
| 95 |
+
{/* Overlay gradient */}
|
| 96 |
+
<div className="absolute inset-0 bg-gradient-to-t from-background via-transparent to-transparent opacity-60 group-hover:opacity-40 transition-opacity duration-500" />
|
| 97 |
+
|
| 98 |
+
{/* Hover overlay with pattern */}
|
| 99 |
+
<div className="absolute inset-0 bg-primary/20 opacity-0 group-hover:opacity-100 transition-opacity duration-500 flex items-center justify-center">
|
| 100 |
+
<div className="w-12 h-12 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center transform scale-0 group-hover:scale-100 transition-transform duration-500">
|
| 101 |
+
<Eye className="w-5 h-5 text-white" suppressHydrationWarning />
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
{/* Category badge */}
|
| 106 |
+
<div className="absolute top-4 left-4">
|
| 107 |
+
<span className="px-3 py-1.5 rounded-full text-xs font-semibold uppercase tracking-wider glass-card">
|
| 108 |
+
{project.category}
|
| 109 |
+
</span>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<CardHeader className="relative pb-3">
|
| 114 |
+
<h3 className="text-xl font-bold leading-tight group-hover:text-primary transition-colors duration-300 flex items-center gap-2">
|
| 115 |
+
{project.title}
|
| 116 |
+
<ArrowUpRight className="w-4 h-4 opacity-0 -translate-x-2 -translate-y-2 group-hover:opacity-100 group-hover:translate-x-0 group-hover:translate-y-0 transition-all duration-300 text-primary" suppressHydrationWarning />
|
| 117 |
+
</h3>
|
| 118 |
+
</CardHeader>
|
| 119 |
+
|
| 120 |
+
<CardContent>
|
| 121 |
+
<div className="flex flex-wrap gap-2">
|
| 122 |
+
{project.tags.slice(0, 4).map((tag, tagIndex) => (
|
| 123 |
+
<span
|
| 124 |
+
key={tag}
|
| 125 |
+
className="px-3 py-1.5 rounded-full text-xs font-medium glass-card transition-all duration-300 hover:scale-105 hover:bg-primary/20"
|
| 126 |
+
style={{ transitionDelay: `${tagIndex * 50}ms` }}
|
| 127 |
+
>
|
| 128 |
+
{tag}
|
| 129 |
+
</span>
|
| 130 |
+
))}
|
| 131 |
+
{project.tags.length > 4 && (
|
| 132 |
+
<span className="px-3 py-1.5 rounded-full text-xs font-medium text-primary">
|
| 133 |
+
+{project.tags.length - 4} more
|
| 134 |
+
</span>
|
| 135 |
+
)}
|
| 136 |
+
</div>
|
| 137 |
+
</CardContent>
|
| 138 |
+
|
| 139 |
+
{/* Bottom accent line */}
|
| 140 |
+
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-primary via-accent to-primary transform scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left" />
|
| 141 |
+
|
| 142 |
+
{/* Shine effect */}
|
| 143 |
+
<div className="absolute inset-0 shine-effect pointer-events-none rounded-3xl" />
|
| 144 |
+
</Card>
|
| 145 |
+
</Link>
|
| 146 |
+
</article>
|
| 147 |
+
)
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
export function FeaturedPortfolioWrapper() {
|
| 151 |
+
const [projects, setProjects] = useState<Project[]>([])
|
| 152 |
+
const [isVisible, setIsVisible] = useState(false)
|
| 153 |
+
const sectionRef = useRef<HTMLElement>(null)
|
| 154 |
+
|
| 155 |
+
useEffect(() => {
|
| 156 |
+
getFeaturedProjects().then(setProjects)
|
| 157 |
+
}, [])
|
| 158 |
+
|
| 159 |
+
useEffect(() => {
|
| 160 |
+
const observer = new IntersectionObserver(
|
| 161 |
+
([entry]) => {
|
| 162 |
+
if (entry.isIntersecting) {
|
| 163 |
+
setIsVisible(true)
|
| 164 |
+
}
|
| 165 |
+
},
|
| 166 |
+
{ threshold: 0.1 }
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
if (sectionRef.current) {
|
| 170 |
+
observer.observe(sectionRef.current)
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
return () => observer.disconnect()
|
| 174 |
+
}, [])
|
| 175 |
+
|
| 176 |
+
return (
|
| 177 |
+
<section ref={sectionRef} className="py-24 bg-muted/20 relative overflow-hidden" id="portfolio">
|
| 178 |
+
{/* Background decorations */}
|
| 179 |
+
<div className="absolute top-1/2 left-0 w-72 h-72 bg-primary/10 rounded-full blur-3xl -translate-y-1/2" />
|
| 180 |
+
<div className="absolute top-1/4 right-0 w-96 h-96 bg-accent/10 rounded-full blur-3xl" />
|
| 181 |
+
|
| 182 |
+
{/* Animated grid pattern */}
|
| 183 |
+
<div className="absolute inset-0 opacity-5">
|
| 184 |
+
<div className="absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--primary))_1px,transparent_1px),linear-gradient(to_bottom,hsl(var(--primary))_1px,transparent_1px)] bg-size-[48px_48px]" />
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<div className="container relative z-10">
|
| 188 |
+
{/* Section header */}
|
| 189 |
+
<div className={`flex flex-col md:flex-row justify-between items-start md:items-end mb-14 gap-6 transition-all duration-1000 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`}>
|
| 190 |
+
<div className="max-w-2xl">
|
| 191 |
+
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass-card mb-6">
|
| 192 |
+
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
| 193 |
+
<span className="text-sm font-medium text-primary">Portfolio</span>
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
<h2 className="text-4xl md:text-5xl mb-6 font-bold tracking-tight">
|
| 197 |
+
Featured <span className="gradient-text">Work</span>
|
| 198 |
+
</h2>
|
| 199 |
+
|
| 200 |
+
<p className="text-lg text-muted-foreground leading-relaxed">
|
| 201 |
+
Explore our latest projects and the impact we've made for our clients.
|
| 202 |
+
</p>
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
<Button asChild variant="outline" className="group glass-card hover:bg-primary/10 shrink-0">
|
| 206 |
+
<Link href="/portfolio">
|
| 207 |
+
View All Projects
|
| 208 |
+
<ExternalLink className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" suppressHydrationWarning />
|
| 209 |
+
</Link>
|
| 210 |
+
</Button>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
{/* Projects grid */}
|
| 214 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
| 215 |
+
{projects.map((project, i) => (
|
| 216 |
+
<PortfolioCard key={project.slug} project={project} index={i} />
|
| 217 |
+
))}
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
{/* Empty state */}
|
| 221 |
+
{projects.length === 0 && (
|
| 222 |
+
<div className="text-center py-16">
|
| 223 |
+
<p className="text-muted-foreground">No projects to display yet.</p>
|
| 224 |
+
</div>
|
| 225 |
+
)}
|
| 226 |
+
|
| 227 |
+
{/* Bottom stats */}
|
| 228 |
+
<div className={`mt-16 flex justify-center gap-8 md:gap-16 transition-all duration-1000 delay-500 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`}>
|
| 229 |
+
{[
|
| 230 |
+
{ value: "50+", label: "Projects Completed" },
|
| 231 |
+
{ value: "30+", label: "Happy Clients" },
|
| 232 |
+
{ value: "5+", label: "Years Experience" },
|
| 233 |
+
].map((stat) => (
|
| 234 |
+
<div key={stat.label} className="text-center group cursor-default">
|
| 235 |
+
<div className="text-2xl md:text-3xl font-bold gradient-text group-hover:scale-110 transition-transform duration-300">
|
| 236 |
+
{stat.value}
|
| 237 |
+
</div>
|
| 238 |
+
<div className="text-xs md:text-sm text-muted-foreground mt-1">
|
| 239 |
+
{stat.label}
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
))}
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
</section>
|
| 246 |
+
)
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// Export the wrapped component as the default
|
| 250 |
+
export function FeaturedPortfolio() {
|
| 251 |
+
return <FeaturedPortfolioWrapper />
|
| 252 |
+
}
|
components/home/HeroSection.tsx
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import Link from "next/link"
|
| 4 |
+
import { Button } from "@/components/ui/button"
|
| 5 |
+
import { ArrowRight, Sparkles, Code2, Rocket, Zap } from "lucide-react"
|
| 6 |
+
import { useEffect, useRef, useState } from "react"
|
| 7 |
+
|
| 8 |
+
// Pre-computed particle data to avoid hydration mismatch
|
| 9 |
+
// These values are static and will be the same on server and client
|
| 10 |
+
const PARTICLES = [
|
| 11 |
+
// Top-left quadrant
|
| 12 |
+
{ left: 5.2, top: 8.7, delay: 2.4, duration: 18.6, width: 5.8, height: 5.2, opacity: 0.32 },
|
| 13 |
+
{ left: 12.8, top: 15.3, delay: 7.1, duration: 21.2, width: 4.5, height: 6.8, opacity: 0.28 },
|
| 14 |
+
{ left: 8.4, top: 28.9, delay: 11.5, duration: 19.8, width: 6.2, height: 4.9, opacity: 0.35 },
|
| 15 |
+
{ left: 18.6, top: 6.2, delay: 4.8, duration: 22.4, width: 5.1, height: 5.7, opacity: 0.30 },
|
| 16 |
+
{ left: 3.9, top: 42.5, delay: 9.3, duration: 17.5, width: 4.8, height: 6.3, opacity: 0.26 },
|
| 17 |
+
{ left: 15.3, top: 35.8, delay: 14.7, duration: 20.1, width: 6.5, height: 5.4, opacity: 0.33 },
|
| 18 |
+
|
| 19 |
+
// Top-right quadrant
|
| 20 |
+
{ left: 82.4, top: 5.8, delay: 5.2, duration: 19.4, width: 5.6, height: 6.1, opacity: 0.31 },
|
| 21 |
+
{ left: 91.7, top: 18.4, delay: 10.8, duration: 22.8, width: 4.2, height: 5.5, opacity: 0.27 },
|
| 22 |
+
{ left: 76.3, top: 12.6, delay: 3.1, duration: 18.2, width: 6.8, height: 4.6, opacity: 0.34 },
|
| 23 |
+
{ left: 88.5, top: 32.9, delay: 8.9, duration: 21.6, width: 5.3, height: 6.7, opacity: 0.29 },
|
| 24 |
+
{ left: 94.2, top: 8.3, delay: 13.4, duration: 17.9, width: 4.9, height: 5.8, opacity: 0.32 },
|
| 25 |
+
|
| 26 |
+
// Bottom-left quadrant
|
| 27 |
+
{ left: 6.8, top: 72.4, delay: 6.5, duration: 20.3, width: 5.9, height: 5.1, opacity: 0.28 },
|
| 28 |
+
{ left: 14.2, top: 85.6, delay: 12.1, duration: 18.7, width: 4.6, height: 6.4, opacity: 0.33 },
|
| 29 |
+
{ left: 9.7, top: 58.3, delay: 1.8, duration: 22.1, width: 6.1, height: 4.8, opacity: 0.30 },
|
| 30 |
+
{ left: 22.5, top: 91.8, delay: 9.6, duration: 19.5, width: 5.4, height: 5.9, opacity: 0.26 },
|
| 31 |
+
|
| 32 |
+
// Bottom-right quadrant
|
| 33 |
+
{ left: 85.3, top: 78.2, delay: 4.3, duration: 21.8, width: 4.7, height: 6.2, opacity: 0.31 },
|
| 34 |
+
{ left: 92.8, top: 65.4, delay: 11.2, duration: 18.4, width: 5.2, height: 5.6, opacity: 0.29 },
|
| 35 |
+
{ left: 78.6, top: 88.9, delay: 7.8, duration: 20.7, width: 6.4, height: 4.5, opacity: 0.34 },
|
| 36 |
+
{ left: 96.1, top: 82.5, delay: 2.9, duration: 22.6, width: 5.8, height: 6.1, opacity: 0.27 },
|
| 37 |
+
|
| 38 |
+
// Center spread (top area only to avoid stat cards)
|
| 39 |
+
{ left: 35.4, top: 12.8, delay: 8.4, duration: 19.1, width: 5.5, height: 5.3, opacity: 0.30 },
|
| 40 |
+
{ left: 48.7, top: 8.2, delay: 3.6, duration: 21.4, width: 4.4, height: 6.6, opacity: 0.28 },
|
| 41 |
+
{ left: 62.3, top: 15.9, delay: 10.2, duration: 18.9, width: 6.3, height: 5.0, opacity: 0.32 },
|
| 42 |
+
{ left: 55.8, top: 5.4, delay: 6.1, duration: 22.2, width: 5.1, height: 5.8, opacity: 0.29 },
|
| 43 |
+
{ left: 41.2, top: 22.6, delay: 12.8, duration: 17.8, width: 5.7, height: 4.7, opacity: 0.31 },
|
| 44 |
+
{ left: 68.9, top: 10.3, delay: 5.4, duration: 20.5, width: 4.9, height: 6.0, opacity: 0.33 },
|
| 45 |
+
{ left: 29.6, top: 3.8, delay: 9.7, duration: 21.9, width: 6.0, height: 5.2, opacity: 0.27 },
|
| 46 |
+
{ left: 72.4, top: 18.5, delay: 2.2, duration: 19.3, width: 5.3, height: 5.5, opacity: 0.30 },
|
| 47 |
+
{ left: 45.1, top: 25.7, delay: 7.5, duration: 18.1, width: 4.6, height: 6.3, opacity: 0.28 },
|
| 48 |
+
{ left: 58.6, top: 2.9, delay: 11.8, duration: 22.7, width: 6.2, height: 4.8, opacity: 0.32 },
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
export function HeroSection() {
|
| 52 |
+
const heroRef = useRef<HTMLDivElement>(null)
|
| 53 |
+
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
|
| 54 |
+
|
| 55 |
+
useEffect(() => {
|
| 56 |
+
const handleMouseMove = (e: MouseEvent) => {
|
| 57 |
+
if (heroRef.current) {
|
| 58 |
+
const rect = heroRef.current.getBoundingClientRect()
|
| 59 |
+
setMousePosition({
|
| 60 |
+
x: (e.clientX - rect.left) / rect.width,
|
| 61 |
+
y: (e.clientY - rect.top) / rect.height,
|
| 62 |
+
})
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
window.addEventListener('mousemove', handleMouseMove)
|
| 67 |
+
return () => window.removeEventListener('mousemove', handleMouseMove)
|
| 68 |
+
}, [])
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<section ref={heroRef} className="relative overflow-hidden pt-20 pb-32 md:pt-28 lg:pt-36 xl:pt-44 bg-background min-h-screen flex items-center">
|
| 72 |
+
{/* Animated gradient mesh background */}
|
| 73 |
+
<div className="absolute inset-0 gradient-mesh opacity-40" />
|
| 74 |
+
|
| 75 |
+
{/* Noise overlay for depth */}
|
| 76 |
+
<div className="absolute inset-0 noise-overlay" />
|
| 77 |
+
|
| 78 |
+
{/* Morphing blob 1 */}
|
| 79 |
+
<div
|
| 80 |
+
className="absolute w-[500px] h-[500px] morphing-blob opacity-30 blur-3xl pointer-events-none"
|
| 81 |
+
style={{
|
| 82 |
+
background: `linear-gradient(135deg, hsl(var(--primary)), hsl(280, 65%, 60%))`,
|
| 83 |
+
top: '10%',
|
| 84 |
+
right: '5%',
|
| 85 |
+
transform: `translate(${mousePosition.x * 30}px, ${mousePosition.y * 30}px)`,
|
| 86 |
+
transition: 'transform 0.3s ease-out',
|
| 87 |
+
}}
|
| 88 |
+
/>
|
| 89 |
+
|
| 90 |
+
{/* Morphing blob 2 */}
|
| 91 |
+
<div
|
| 92 |
+
className="absolute w-[400px] h-[400px] morphing-blob opacity-20 blur-3xl pointer-events-none"
|
| 93 |
+
style={{
|
| 94 |
+
background: `linear-gradient(135deg, hsl(280, 65%, 60%), hsl(199, 89%, 48%))`,
|
| 95 |
+
bottom: '10%',
|
| 96 |
+
left: '5%',
|
| 97 |
+
transform: `translate(${-mousePosition.x * 20}px, ${-mousePosition.y * 20}px)`,
|
| 98 |
+
transition: 'transform 0.3s ease-out',
|
| 99 |
+
animationDelay: '-5s',
|
| 100 |
+
}}
|
| 101 |
+
/>
|
| 102 |
+
|
| 103 |
+
{/* Floating particles - behind all content */}
|
| 104 |
+
<div className="absolute inset-0 pointer-events-none overflow-hidden -z-10">
|
| 105 |
+
{PARTICLES.map((particle, i) => (
|
| 106 |
+
<div
|
| 107 |
+
key={i}
|
| 108 |
+
className="particle"
|
| 109 |
+
style={{
|
| 110 |
+
left: `${particle.left}%`,
|
| 111 |
+
top: `${particle.top}%`,
|
| 112 |
+
animationDelay: `${particle.delay}s`,
|
| 113 |
+
animationDuration: `${particle.duration}s`,
|
| 114 |
+
width: `${particle.width}px`,
|
| 115 |
+
height: `${particle.height}px`,
|
| 116 |
+
opacity: particle.opacity,
|
| 117 |
+
}}
|
| 118 |
+
/>
|
| 119 |
+
))}
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
{/* Aurora effect */}
|
| 123 |
+
<div className="absolute inset-0 aurora-bg opacity-30" />
|
| 124 |
+
|
| 125 |
+
{/* Subtle grid pattern */}
|
| 126 |
+
<div className="absolute inset-0 -z-10 opacity-10">
|
| 127 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,hsl(var(--muted))_1px,transparent_1px)] bg-size-[32px_32px]" />
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<div className="container relative z-10 flex flex-col items-center text-center">
|
| 131 |
+
{/* Animated badge */}
|
| 132 |
+
<div className="animate-fade-in-up mb-8 inline-flex items-center gap-2 rounded-full glass-card px-6 py-3 text-sm font-medium transition-all duration-500 hover:scale-105 hover:shadow-lg group cursor-default">
|
| 133 |
+
<span className="relative flex h-2 w-2">
|
| 134 |
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
| 135 |
+
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
| 136 |
+
</span>
|
| 137 |
+
<Sparkles className="h-4 w-4 text-primary animate-pulse" suppressHydrationWarning />
|
| 138 |
+
<span className="interactive-underline cursor-pointer">Your Vision, Our Craft</span>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
{/* H1 with creative styling */}
|
| 142 |
+
<h1 className="animate-fade-in-up animate-delay-1 mb-8 text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-extrabold tracking-tight leading-tight">
|
| 143 |
+
<span className="block">We Design & Build</span>
|
| 144 |
+
<span className="relative inline-block mt-2">
|
| 145 |
+
<span className="gradient-text">Digital Products</span>
|
| 146 |
+
<svg className="absolute -bottom-2 left-0 w-full" viewBox="0 0 300 12" fill="none">
|
| 147 |
+
<path d="M2 10C50 4 150 2 298 10" stroke="hsl(var(--primary))" strokeWidth="3" strokeLinecap="round" className="animate-dash" style={{
|
| 148 |
+
strokeDasharray: '300',
|
| 149 |
+
strokeDashoffset: '300',
|
| 150 |
+
animation: 'dash 2s ease forwards 0.5s'
|
| 151 |
+
}} />
|
| 152 |
+
</svg>
|
| 153 |
+
</span>
|
| 154 |
+
<span className="block mt-2 wave-text">
|
| 155 |
+
<span>T</span><span>h</span><span>a</span><span>t</span>
|
| 156 |
+
<span className="mx-2"> </span>
|
| 157 |
+
<span className="text-primary neon-glow">GROW</span>
|
| 158 |
+
</span>
|
| 159 |
+
</h1>
|
| 160 |
+
|
| 161 |
+
{/* Subheadline */}
|
| 162 |
+
<p className="animate-fade-in-up animate-delay-2 mb-12 max-w-2xl text-lg sm:text-xl text-muted-foreground leading-relaxed">
|
| 163 |
+
From stunning websites to powerful mobile apps — we turn ambitious ideas
|
| 164 |
+
into high-performance digital experiences that your users will{" "}
|
| 165 |
+
<span className="text-primary font-medium">love</span>.
|
| 166 |
+
</p>
|
| 167 |
+
|
| 168 |
+
{/* Enhanced CTAs */}
|
| 169 |
+
<div className="animate-fade-in-up animate-delay-3 flex flex-col gap-4 sm:flex-row">
|
| 170 |
+
<Button asChild size="xl" className="group relative overflow-hidden shine-effect ripple-button">
|
| 171 |
+
<Link href="/quote">
|
| 172 |
+
<span className="relative z-10 flex items-center">
|
| 173 |
+
Get Started
|
| 174 |
+
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" suppressHydrationWarning />
|
| 175 |
+
</span>
|
| 176 |
+
</Link>
|
| 177 |
+
</Button>
|
| 178 |
+
<Button asChild variant="outline" size="xl" className="group glass-card hover:bg-primary/10">
|
| 179 |
+
<Link href="/portfolio">
|
| 180 |
+
<span className="flex items-center">
|
| 181 |
+
View Portfolio
|
| 182 |
+
<Rocket className="ml-2 h-4 w-4 transition-transform group-hover:rotate-12" suppressHydrationWarning />
|
| 183 |
+
</span>
|
| 184 |
+
</Link>
|
| 185 |
+
</Button>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
{/* Floating tech icons */}
|
| 189 |
+
<div className="absolute top-1/4 left-10 hidden xl:block animate-float" style={{ animationDelay: '0s' }}>
|
| 190 |
+
<div className="glass-card p-3 rounded-xl">
|
| 191 |
+
<Code2 className="h-6 w-6 text-primary" suppressHydrationWarning />
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
<div className="absolute top-1/3 right-10 hidden xl:block animate-float" style={{ animationDelay: '1s' }}>
|
| 195 |
+
<div className="glass-card p-3 rounded-xl">
|
| 196 |
+
<Zap className="h-6 w-6 text-amber-500" suppressHydrationWarning />
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
<div className="absolute bottom-1/3 left-20 hidden xl:block animate-float" style={{ animationDelay: '2s' }}>
|
| 200 |
+
<div className="glass-card p-3 rounded-xl">
|
| 201 |
+
<Rocket className="h-6 w-6 text-cyan-500" suppressHydrationWarning />
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
{/* Key Statistics with enhanced styling */}
|
| 206 |
+
<div className="animate-fade-in-up animate-delay-5 mt-24 relative">
|
| 207 |
+
{/* Background overlay to hide particles behind cards */}
|
| 208 |
+
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/95 to-background/80 -z-10 rounded-3xl" />
|
| 209 |
+
|
| 210 |
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 md:gap-8 w-full max-w-4xl">
|
| 211 |
+
{[
|
| 212 |
+
{ value: "50+", label: "Projects Shipped", icon: Rocket },
|
| 213 |
+
{ value: "98%", label: "Client Satisfaction", icon: Sparkles },
|
| 214 |
+
{ value: "5+", label: "Years of Expertise", icon: Zap },
|
| 215 |
+
].map((stat, i) => (
|
| 216 |
+
<div
|
| 217 |
+
key={stat.label}
|
| 218 |
+
className="group relative text-center cursor-default"
|
| 219 |
+
>
|
| 220 |
+
{/* Animated border */}
|
| 221 |
+
<div className="absolute inset-0 rounded-3xl animated-border opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
| 222 |
+
|
| 223 |
+
<div className="relative glass-card rounded-3xl p-8 transition-all duration-500 hover:scale-105 hover:-translate-y-2">
|
| 224 |
+
{/* Icon */}
|
| 225 |
+
<stat.icon className="h-6 w-6 text-primary mx-auto mb-3 opacity-60 group-hover:opacity-100 group-hover:scale-110 transition-all duration-300" suppressHydrationWarning />
|
| 226 |
+
|
| 227 |
+
{/* Value with counter animation */}
|
| 228 |
+
<div className="text-4xl md:text-5xl font-bold gradient-text mb-2 transition-all duration-300 group-hover:scale-110 counter-animate">
|
| 229 |
+
{stat.value}
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
{/* Label */}
|
| 233 |
+
<p className="text-sm text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
|
| 234 |
+
{stat.label}
|
| 235 |
+
</p>
|
| 236 |
+
|
| 237 |
+
{/* Shine effect on hover */}
|
| 238 |
+
<div className="absolute inset-0 rounded-3xl shine-effect pointer-events-none" />
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
))}
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
{/* Scroll indicator */}
|
| 246 |
+
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-fade-in animate-delay-7">
|
| 247 |
+
<div className="scroll-indicator cursor-pointer hover:scale-110 transition-transform" onClick={() => {
|
| 248 |
+
window.scrollTo({ top: window.innerHeight, behavior: 'smooth' })
|
| 249 |
+
}} />
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
{/* CSS for dash animation */}
|
| 254 |
+
<style jsx>{`
|
| 255 |
+
@keyframes dash {
|
| 256 |
+
to {
|
| 257 |
+
stroke-dashoffset: 0;
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
`}</style>
|
| 261 |
+
</section>
|
| 262 |
+
)
|
| 263 |
+
}
|
components/home/ServicesOverview.tsx
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
| 4 |
+
import { Code, Palette, Smartphone, LineChart, ArrowUpRight, Sparkles } from "lucide-react"
|
| 5 |
+
import Link from "next/link"
|
| 6 |
+
import { useRef, useState, useEffect } from "react"
|
| 7 |
+
|
| 8 |
+
const services = [
|
| 9 |
+
{
|
| 10 |
+
title: "Web Development",
|
| 11 |
+
description: "Custom websites and web applications built with modern technologies like Next.js, React, and Node.js.",
|
| 12 |
+
icon: Code,
|
| 13 |
+
gradient: "from-blue-500 via-cyan-400 to-teal-400",
|
| 14 |
+
accentColor: "hsl(199, 89%, 48%)",
|
| 15 |
+
features: ["Next.js", "React", "Node.js"],
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
title: "Mobile App Development",
|
| 19 |
+
description: "Native and cross-platform mobile apps for iOS and Android using React Native and Flutter.",
|
| 20 |
+
icon: Smartphone,
|
| 21 |
+
gradient: "from-violet-500 via-purple-400 to-fuchsia-400",
|
| 22 |
+
accentColor: "hsl(280, 65%, 60%)",
|
| 23 |
+
features: ["React Native", "Flutter", "iOS & Android"],
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
title: "UI/UX Design",
|
| 27 |
+
description: "User-centric design solutions that enhance engagement, usability, and brand identity.",
|
| 28 |
+
icon: Palette,
|
| 29 |
+
gradient: "from-pink-500 via-rose-400 to-orange-400",
|
| 30 |
+
accentColor: "hsl(340, 75%, 55%)",
|
| 31 |
+
features: ["Figma", "Prototyping", "Design Systems"],
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
title: "SEO Optimization",
|
| 35 |
+
description: "Data-driven strategies to improve your search engine rankings, visibility, and organic traffic.",
|
| 36 |
+
icon: LineChart,
|
| 37 |
+
gradient: "from-amber-500 via-orange-400 to-red-400",
|
| 38 |
+
accentColor: "hsl(30, 80%, 55%)",
|
| 39 |
+
features: ["Analytics", "Keyword Research", "Technical SEO"],
|
| 40 |
+
},
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
function ServiceCard({ service, index }: { service: typeof services[0]; index: number }) {
|
| 44 |
+
const cardRef = useRef<HTMLDivElement>(null)
|
| 45 |
+
const [rotateX, setRotateX] = useState(0)
|
| 46 |
+
const [rotateY, setRotateY] = useState(0)
|
| 47 |
+
const [isHovered, setIsHovered] = useState(false)
|
| 48 |
+
|
| 49 |
+
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
| 50 |
+
if (!cardRef.current) return
|
| 51 |
+
const rect = cardRef.current.getBoundingClientRect()
|
| 52 |
+
const centerX = rect.left + rect.width / 2
|
| 53 |
+
const centerY = rect.top + rect.height / 2
|
| 54 |
+
const mouseX = e.clientX - centerX
|
| 55 |
+
const mouseY = e.clientY - centerY
|
| 56 |
+
|
| 57 |
+
setRotateX(-mouseY / 10)
|
| 58 |
+
setRotateY(mouseX / 10)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const handleMouseLeave = () => {
|
| 62 |
+
setRotateX(0)
|
| 63 |
+
setRotateY(0)
|
| 64 |
+
setIsHovered(false)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
<div
|
| 69 |
+
ref={cardRef}
|
| 70 |
+
className="group relative h-full"
|
| 71 |
+
style={{
|
| 72 |
+
perspective: '1000px',
|
| 73 |
+
animationDelay: `${index * 0.1}s`,
|
| 74 |
+
}}
|
| 75 |
+
>
|
| 76 |
+
<Link href="/services" aria-label={`Learn more about ${service.title}`} className="block h-full">
|
| 77 |
+
<div
|
| 78 |
+
className="relative h-full cursor-pointer transition-all duration-300"
|
| 79 |
+
style={{
|
| 80 |
+
transform: `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`,
|
| 81 |
+
transformStyle: 'preserve-3d',
|
| 82 |
+
}}
|
| 83 |
+
onMouseMove={handleMouseMove}
|
| 84 |
+
onMouseEnter={() => setIsHovered(true)}
|
| 85 |
+
onMouseLeave={handleMouseLeave}
|
| 86 |
+
>
|
| 87 |
+
{/* Glow effect */}
|
| 88 |
+
<div
|
| 89 |
+
className="absolute -inset-1 rounded-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 blur-xl"
|
| 90 |
+
style={{
|
| 91 |
+
background: `linear-gradient(135deg, ${service.accentColor}, transparent)`,
|
| 92 |
+
}}
|
| 93 |
+
/>
|
| 94 |
+
|
| 95 |
+
{/* Card */}
|
| 96 |
+
<Card className="relative h-full overflow-hidden glass-card rounded-3xl border-0 transition-all duration-300 group-hover:shadow-2xl">
|
| 97 |
+
{/* Animated gradient border */}
|
| 98 |
+
<div
|
| 99 |
+
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
| 100 |
+
style={{
|
| 101 |
+
background: `linear-gradient(135deg, ${service.accentColor}20, transparent, ${service.accentColor}20)`,
|
| 102 |
+
}}
|
| 103 |
+
/>
|
| 104 |
+
|
| 105 |
+
{/* Spotlight effect */}
|
| 106 |
+
<div
|
| 107 |
+
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
| 108 |
+
style={{
|
| 109 |
+
background: `radial-gradient(600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), ${service.accentColor}15, transparent 40%)`,
|
| 110 |
+
}}
|
| 111 |
+
/>
|
| 112 |
+
|
| 113 |
+
<CardHeader className="relative z-10 pb-4">
|
| 114 |
+
{/* Icon container */}
|
| 115 |
+
<div
|
| 116 |
+
className={`relative w-16 h-16 rounded-2xl flex items-center justify-center mb-5 transition-all duration-500 group-hover:scale-110 group-hover:rotate-3`}
|
| 117 |
+
style={{
|
| 118 |
+
background: `linear-gradient(135deg, ${service.accentColor}20, ${service.accentColor}10)`,
|
| 119 |
+
boxShadow: isHovered ? `0 10px 40px ${service.accentColor}30` : 'none',
|
| 120 |
+
}}
|
| 121 |
+
>
|
| 122 |
+
<service.icon
|
| 123 |
+
className="w-8 h-8 transition-all duration-300"
|
| 124 |
+
style={{ color: service.accentColor }}
|
| 125 |
+
suppressHydrationWarning
|
| 126 |
+
/>
|
| 127 |
+
|
| 128 |
+
{/* Sparkle effect on hover */}
|
| 129 |
+
<Sparkles
|
| 130 |
+
className="absolute -top-1 -right-1 w-4 h-4 text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 group-hover:scale-110"
|
| 131 |
+
suppressHydrationWarning
|
| 132 |
+
/>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
{/* Title with arrow */}
|
| 136 |
+
<h3 className="text-xl font-bold leading-tight flex items-center gap-2 group-hover:text-primary transition-colors duration-300">
|
| 137 |
+
{service.title}
|
| 138 |
+
<ArrowUpRight
|
| 139 |
+
className="h-4 w-4 opacity-0 -translate-x-2 -translate-y-2 group-hover:opacity-100 group-hover:translate-x-0 group-hover:translate-y-0 transition-all duration-300 text-primary"
|
| 140 |
+
suppressHydrationWarning
|
| 141 |
+
/>
|
| 142 |
+
</h3>
|
| 143 |
+
</CardHeader>
|
| 144 |
+
|
| 145 |
+
<CardContent className="relative z-10">
|
| 146 |
+
<p className="text-base text-muted-foreground group-hover:text-foreground transition-colors duration-300 leading-relaxed mb-4">
|
| 147 |
+
{service.description}
|
| 148 |
+
</p>
|
| 149 |
+
|
| 150 |
+
{/* Feature tags */}
|
| 151 |
+
<div className="flex flex-wrap gap-2">
|
| 152 |
+
{service.features.map((feature) => (
|
| 153 |
+
<span
|
| 154 |
+
key={feature}
|
| 155 |
+
className="px-3 py-1 rounded-full text-xs font-medium transition-all duration-300 group-hover:scale-105"
|
| 156 |
+
style={{
|
| 157 |
+
background: `${service.accentColor}15`,
|
| 158 |
+
color: service.accentColor,
|
| 159 |
+
}}
|
| 160 |
+
>
|
| 161 |
+
{feature}
|
| 162 |
+
</span>
|
| 163 |
+
))}
|
| 164 |
+
</div>
|
| 165 |
+
</CardContent>
|
| 166 |
+
|
| 167 |
+
{/* Bottom gradient line */}
|
| 168 |
+
<div
|
| 169 |
+
className="absolute bottom-0 left-0 right-0 h-1 opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
| 170 |
+
style={{
|
| 171 |
+
background: `linear-gradient(90deg, transparent, ${service.accentColor}, transparent)`,
|
| 172 |
+
}}
|
| 173 |
+
/>
|
| 174 |
+
|
| 175 |
+
{/* Shine effect */}
|
| 176 |
+
<div className="absolute inset-0 shine-effect pointer-events-none rounded-3xl" />
|
| 177 |
+
</Card>
|
| 178 |
+
</div>
|
| 179 |
+
</Link>
|
| 180 |
+
</div>
|
| 181 |
+
)
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
export function ServicesOverview() {
|
| 185 |
+
const [isVisible, setIsVisible] = useState(false)
|
| 186 |
+
const sectionRef = useRef<HTMLElement>(null)
|
| 187 |
+
|
| 188 |
+
useEffect(() => {
|
| 189 |
+
const observer = new IntersectionObserver(
|
| 190 |
+
([entry]) => {
|
| 191 |
+
if (entry.isIntersecting) {
|
| 192 |
+
setIsVisible(true)
|
| 193 |
+
}
|
| 194 |
+
},
|
| 195 |
+
{ threshold: 0.1 }
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
if (sectionRef.current) {
|
| 199 |
+
observer.observe(sectionRef.current)
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
return () => observer.disconnect()
|
| 203 |
+
}, [])
|
| 204 |
+
|
| 205 |
+
return (
|
| 206 |
+
<section ref={sectionRef} className="py-24 bg-background relative overflow-hidden" id="services">
|
| 207 |
+
{/* Background elements */}
|
| 208 |
+
<div className="absolute inset-0 gradient-mesh opacity-20" />
|
| 209 |
+
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary/5 rounded-full blur-3xl" />
|
| 210 |
+
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-accent/5 rounded-full blur-3xl" />
|
| 211 |
+
|
| 212 |
+
<div className="container relative z-10">
|
| 213 |
+
{/* Section header with reveal animation */}
|
| 214 |
+
<div className={`text-center mb-16 transition-all duration-1000 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`}>
|
| 215 |
+
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass-card mb-6">
|
| 216 |
+
<Sparkles className="h-4 w-4 text-primary animate-pulse" suppressHydrationWarning />
|
| 217 |
+
<span className="text-sm font-medium text-primary">What We Do</span>
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
<h2 className="text-4xl md:text-5xl mb-6 font-bold tracking-tight">
|
| 221 |
+
Our <span className="gradient-text">Expertise</span>
|
| 222 |
+
</h2>
|
| 223 |
+
|
| 224 |
+
<p className="text-lg text-muted-foreground max-w-2xl mx-auto leading-relaxed">
|
| 225 |
+
We offer a comprehensive range of digital services to help your business{" "}
|
| 226 |
+
<span className="text-primary font-medium">grow</span> and{" "}
|
| 227 |
+
<span className="text-primary font-medium">succeed</span>.
|
| 228 |
+
</p>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
{/* Services grid */}
|
| 232 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 233 |
+
{services.map((service, i) => (
|
| 234 |
+
<div
|
| 235 |
+
key={service.title}
|
| 236 |
+
className={`transition-all duration-700 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`}
|
| 237 |
+
style={{ transitionDelay: `${i * 100}ms` }}
|
| 238 |
+
>
|
| 239 |
+
<ServiceCard service={service} index={i} />
|
| 240 |
+
</div>
|
| 241 |
+
))}
|
| 242 |
+
</div>
|
| 243 |
+
|
| 244 |
+
{/* Bottom CTA */}
|
| 245 |
+
<div className={`text-center mt-12 transition-all duration-1000 delay-500 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`}>
|
| 246 |
+
<p className="text-muted-foreground">
|
| 247 |
+
Need something custom?{" "}
|
| 248 |
+
<Link href="/contact" className="text-primary hover:underline underline-offset-4 font-medium interactive-underline">
|
| 249 |
+
Let's discuss your project
|
| 250 |
+
</Link>
|
| 251 |
+
</p>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
</section>
|
| 255 |
+
)
|
| 256 |
+
}
|
components/home/TestimonialsCarousel.tsx
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card"
|
| 4 |
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
| 5 |
+
import { Star, Quote, ChevronLeft, ChevronRight } from "lucide-react"
|
| 6 |
+
import { useRef, useState, useEffect, useCallback } from "react"
|
| 7 |
+
|
| 8 |
+
type Testimonial = {
|
| 9 |
+
id: string
|
| 10 |
+
clientName: string
|
| 11 |
+
company: string | null
|
| 12 |
+
content: string
|
| 13 |
+
rating: number
|
| 14 |
+
imageUrl: string | null
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
/* Static fallback used when the API is unreachable (build-time, etc.) */
|
| 18 |
+
const fallbackTestimonials: Testimonial[] = [
|
| 19 |
+
{
|
| 20 |
+
id: "fallback-1",
|
| 21 |
+
clientName: "Sarah Johnson",
|
| 22 |
+
company: "TechFlow",
|
| 23 |
+
content:
|
| 24 |
+
"NexaFlow transformed our online presence. Their attention to detail and technical expertise is unmatched. They delivered exactly what we needed.",
|
| 25 |
+
rating: 5,
|
| 26 |
+
imageUrl: null,
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
id: "fallback-2",
|
| 30 |
+
clientName: "Michael Chen",
|
| 31 |
+
company: "StartupX",
|
| 32 |
+
content:
|
| 33 |
+
"The team delivered our MVP ahead of schedule and it looks fantastic. They truly understood our vision and brought it to life.",
|
| 34 |
+
rating: 5,
|
| 35 |
+
imageUrl: null,
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
id: "fallback-3",
|
| 39 |
+
clientName: "Emily Davis",
|
| 40 |
+
company: "Creative Studio",
|
| 41 |
+
content:
|
| 42 |
+
"Professional, responsive, and incredibly talented. They truly understand modern web design and the latest technologies.",
|
| 43 |
+
rating: 5,
|
| 44 |
+
imageUrl: null,
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
id: "fallback-4",
|
| 48 |
+
clientName: "David Wilson",
|
| 49 |
+
company: "InnovateCo",
|
| 50 |
+
content:
|
| 51 |
+
"Working with NexaFlow was a game-changer for our business. The website they built exceeded our expectations in every way.",
|
| 52 |
+
rating: 5,
|
| 53 |
+
imageUrl: null,
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
id: "fallback-5",
|
| 57 |
+
clientName: "Lisa Anderson",
|
| 58 |
+
company: "GrowthHub",
|
| 59 |
+
content:
|
| 60 |
+
"From concept to launch, the process was smooth and professional. Our new app has received amazing feedback from users.",
|
| 61 |
+
rating: 5,
|
| 62 |
+
imageUrl: null,
|
| 63 |
+
},
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
function getInitials(name: string): string {
|
| 67 |
+
return name
|
| 68 |
+
.split(" ")
|
| 69 |
+
.map((w) => w[0])
|
| 70 |
+
.join("")
|
| 71 |
+
.toUpperCase()
|
| 72 |
+
.slice(0, 2)
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function TestimonialCard({ testimonial, isActive }: { testimonial: Testimonial; isActive: boolean }) {
|
| 76 |
+
return (
|
| 77 |
+
<Card
|
| 78 |
+
className={`relative h-full glass-card border-0 transition-all duration-500 rounded-3xl overflow-hidden ${
|
| 79 |
+
isActive ? "scale-100 opacity-100" : "scale-95 opacity-70"
|
| 80 |
+
}`}
|
| 81 |
+
>
|
| 82 |
+
{/* Animated gradient background */}
|
| 83 |
+
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
| 84 |
+
|
| 85 |
+
{/* Quote decoration */}
|
| 86 |
+
<div className="absolute top-6 right-6 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300" aria-hidden="true">
|
| 87 |
+
<Quote className="h-10 w-10 text-primary/20" suppressHydrationWarning />
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<CardHeader className="pb-2 relative z-10">
|
| 91 |
+
<div className="flex gap-0.5 mb-4" role="img" aria-label={`${testimonial.rating} out of 5 stars`}>
|
| 92 |
+
{[...Array(testimonial.rating)].map((_, j) => (
|
| 93 |
+
<Star
|
| 94 |
+
key={j}
|
| 95 |
+
className="w-5 h-5 fill-amber-400 text-amber-400 group-hover:scale-110 transition-all duration-300"
|
| 96 |
+
style={{ transitionDelay: `${j * 50}ms` }}
|
| 97 |
+
suppressHydrationWarning
|
| 98 |
+
/>
|
| 99 |
+
))}
|
| 100 |
+
</div>
|
| 101 |
+
</CardHeader>
|
| 102 |
+
|
| 103 |
+
<CardContent className="relative z-10">
|
| 104 |
+
<blockquote className="text-base text-foreground leading-relaxed">
|
| 105 |
+
“{testimonial.content}”
|
| 106 |
+
</blockquote>
|
| 107 |
+
</CardContent>
|
| 108 |
+
|
| 109 |
+
<CardFooter className="flex items-center gap-4 mt-auto pt-4 relative z-10">
|
| 110 |
+
<Avatar className="h-12 w-12 glass-card group-hover:scale-110 transition-all duration-300 ring-2 ring-primary/20">
|
| 111 |
+
<AvatarFallback className="bg-gradient-to-br from-primary/20 to-accent/20 text-primary text-sm font-semibold">
|
| 112 |
+
{getInitials(testimonial.clientName)}
|
| 113 |
+
</AvatarFallback>
|
| 114 |
+
</Avatar>
|
| 115 |
+
<div>
|
| 116 |
+
<p className="font-semibold text-sm group-hover:text-primary transition-colors duration-300">
|
| 117 |
+
{testimonial.clientName}
|
| 118 |
+
</p>
|
| 119 |
+
{testimonial.company && (
|
| 120 |
+
<p className="text-xs text-muted-foreground">{testimonial.company}</p>
|
| 121 |
+
)}
|
| 122 |
+
</div>
|
| 123 |
+
</CardFooter>
|
| 124 |
+
|
| 125 |
+
{/* Bottom accent line */}
|
| 126 |
+
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-primary via-accent to-primary transform scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left" />
|
| 127 |
+
|
| 128 |
+
{/* Shine effect */}
|
| 129 |
+
<div className="absolute inset-0 shine-effect pointer-events-none rounded-3xl" />
|
| 130 |
+
</Card>
|
| 131 |
+
)
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
export function TestimonialsCarousel() {
|
| 135 |
+
const [testimonials, setTestimonials] = useState<Testimonial[]>(fallbackTestimonials)
|
| 136 |
+
const [currentIndex, setCurrentIndex] = useState(0)
|
| 137 |
+
const [isVisible, setIsVisible] = useState(false)
|
| 138 |
+
const [isPaused, setIsPaused] = useState(false)
|
| 139 |
+
const sectionRef = useRef<HTMLElement>(null)
|
| 140 |
+
|
| 141 |
+
// Fetch testimonials
|
| 142 |
+
useEffect(() => {
|
| 143 |
+
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000/api"
|
| 144 |
+
fetch(`${apiUrl}/testimonials`)
|
| 145 |
+
.then((res) => res.json())
|
| 146 |
+
.then((data: Testimonial[]) => {
|
| 147 |
+
if (data.length > 0) setTestimonials(data)
|
| 148 |
+
})
|
| 149 |
+
.catch(() => {
|
| 150 |
+
// Use fallback testimonials
|
| 151 |
+
})
|
| 152 |
+
}, [])
|
| 153 |
+
|
| 154 |
+
// Intersection observer for reveal animation
|
| 155 |
+
useEffect(() => {
|
| 156 |
+
const observer = new IntersectionObserver(
|
| 157 |
+
([entry]) => {
|
| 158 |
+
if (entry.isIntersecting) {
|
| 159 |
+
setIsVisible(true)
|
| 160 |
+
}
|
| 161 |
+
},
|
| 162 |
+
{ threshold: 0.1 }
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
if (sectionRef.current) {
|
| 166 |
+
observer.observe(sectionRef.current)
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
return () => observer.disconnect()
|
| 170 |
+
}, [])
|
| 171 |
+
|
| 172 |
+
// Auto-scroll carousel
|
| 173 |
+
useEffect(() => {
|
| 174 |
+
if (isPaused || testimonials.length <= 3) return
|
| 175 |
+
|
| 176 |
+
const interval = setInterval(() => {
|
| 177 |
+
setCurrentIndex((prev) => (prev + 1) % testimonials.length)
|
| 178 |
+
}, 5000)
|
| 179 |
+
|
| 180 |
+
return () => clearInterval(interval)
|
| 181 |
+
}, [isPaused, testimonials.length])
|
| 182 |
+
|
| 183 |
+
const goToPrevious = useCallback(() => {
|
| 184 |
+
setCurrentIndex((prev) => (prev - 1 + testimonials.length) % testimonials.length)
|
| 185 |
+
}, [testimonials.length])
|
| 186 |
+
|
| 187 |
+
const goToNext = useCallback(() => {
|
| 188 |
+
setCurrentIndex((prev) => (prev + 1) % testimonials.length)
|
| 189 |
+
}, [testimonials.length])
|
| 190 |
+
|
| 191 |
+
const goToSlide = useCallback((index: number) => {
|
| 192 |
+
setCurrentIndex(index)
|
| 193 |
+
}, [])
|
| 194 |
+
|
| 195 |
+
// Get visible testimonials for carousel
|
| 196 |
+
const getVisibleTestimonials = () => {
|
| 197 |
+
const visible = []
|
| 198 |
+
for (let i = 0; i < 3; i++) {
|
| 199 |
+
const index = (currentIndex + i) % testimonials.length
|
| 200 |
+
visible.push({ testimonial: testimonials[index], position: i })
|
| 201 |
+
}
|
| 202 |
+
return visible
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
return (
|
| 206 |
+
<section
|
| 207 |
+
ref={sectionRef}
|
| 208 |
+
className="py-24 bg-background relative overflow-hidden"
|
| 209 |
+
id="testimonials"
|
| 210 |
+
aria-label="Client testimonials"
|
| 211 |
+
onMouseEnter={() => setIsPaused(true)}
|
| 212 |
+
onMouseLeave={() => setIsPaused(false)}
|
| 213 |
+
>
|
| 214 |
+
{/* Background decorations */}
|
| 215 |
+
<div className="absolute inset-0 gradient-mesh opacity-20" />
|
| 216 |
+
<div className="absolute top-1/4 right-0 w-96 h-96 bg-primary/10 rounded-full blur-3xl" />
|
| 217 |
+
<div className="absolute bottom-1/4 left-0 w-96 h-96 bg-accent/10 rounded-full blur-3xl" />
|
| 218 |
+
|
| 219 |
+
{/* Floating quote icons */}
|
| 220 |
+
<div className="absolute inset-0 overflow-hidden pointer-events-none opacity-5">
|
| 221 |
+
{[...Array(6)].map((_, i) => (
|
| 222 |
+
<Quote
|
| 223 |
+
key={i}
|
| 224 |
+
className="absolute w-16 h-16 text-primary animate-float"
|
| 225 |
+
style={{
|
| 226 |
+
top: `${20 + i * 15}%`,
|
| 227 |
+
left: `${10 + i * 15}%`,
|
| 228 |
+
animationDelay: `${i * 0.5}s`,
|
| 229 |
+
}}
|
| 230 |
+
suppressHydrationWarning
|
| 231 |
+
/>
|
| 232 |
+
))}
|
| 233 |
+
</div>
|
| 234 |
+
|
| 235 |
+
<div className="container relative z-10">
|
| 236 |
+
{/* Section header */}
|
| 237 |
+
<div
|
| 238 |
+
className={`text-center mb-14 transition-all duration-1000 ${
|
| 239 |
+
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10"
|
| 240 |
+
}`}
|
| 241 |
+
>
|
| 242 |
+
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass-card mb-6">
|
| 243 |
+
<Star className="w-4 h-4 text-amber-400 fill-amber-400" suppressHydrationWarning />
|
| 244 |
+
<span className="text-sm font-medium text-primary">Testimonials</span>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
<h2 className="text-4xl md:text-5xl font-bold tracking-tight">
|
| 248 |
+
What Our <span className="gradient-text">Clients</span> Say
|
| 249 |
+
</h2>
|
| 250 |
+
|
| 251 |
+
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mt-4">
|
| 252 |
+
Don't just take our word for it — hear from the businesses we've helped succeed.
|
| 253 |
+
</p>
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
{/* Carousel */}
|
| 257 |
+
<div
|
| 258 |
+
className={`relative transition-all duration-1000 delay-200 ${
|
| 259 |
+
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10"
|
| 260 |
+
}`}
|
| 261 |
+
>
|
| 262 |
+
{/* Navigation buttons */}
|
| 263 |
+
<button
|
| 264 |
+
onClick={goToPrevious}
|
| 265 |
+
className="absolute left-0 top-1/2 -translate-y-1/2 z-20 w-12 h-12 rounded-full glass-card flex items-center justify-center hover:bg-primary/20 transition-all duration-300 hover:scale-110 group"
|
| 266 |
+
aria-label="Previous testimonial"
|
| 267 |
+
>
|
| 268 |
+
<ChevronLeft className="w-6 h-6 text-primary group-hover:-translate-x-0.5 transition-transform" suppressHydrationWarning />
|
| 269 |
+
</button>
|
| 270 |
+
|
| 271 |
+
<button
|
| 272 |
+
onClick={goToNext}
|
| 273 |
+
className="absolute right-0 top-1/2 -translate-y-1/2 z-20 w-12 h-12 rounded-full glass-card flex items-center justify-center hover:bg-primary/20 transition-all duration-300 hover:scale-110 group"
|
| 274 |
+
aria-label="Next testimonial"
|
| 275 |
+
>
|
| 276 |
+
<ChevronRight className="w-6 h-6 text-primary group-hover:translate-x-0.5 transition-transform" suppressHydrationWarning />
|
| 277 |
+
</button>
|
| 278 |
+
|
| 279 |
+
{/* Cards container */}
|
| 280 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 px-8 md:px-16">
|
| 281 |
+
{getVisibleTestimonials().map(({ testimonial, position }) => (
|
| 282 |
+
<div
|
| 283 |
+
key={`${testimonial.id}-${currentIndex}-${position}`}
|
| 284 |
+
className="group"
|
| 285 |
+
style={{
|
| 286 |
+
animationDelay: `${position * 100}ms`,
|
| 287 |
+
}}
|
| 288 |
+
>
|
| 289 |
+
<TestimonialCard testimonial={testimonial} isActive={position === 1} />
|
| 290 |
+
</div>
|
| 291 |
+
))}
|
| 292 |
+
</div>
|
| 293 |
+
</div>
|
| 294 |
+
|
| 295 |
+
{/* Dots navigation */}
|
| 296 |
+
<div
|
| 297 |
+
className={`flex justify-center gap-2 mt-8 transition-all duration-1000 delay-300 ${
|
| 298 |
+
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10"
|
| 299 |
+
}`}
|
| 300 |
+
>
|
| 301 |
+
{testimonials.slice(0, 6).map((_, index) => (
|
| 302 |
+
<button
|
| 303 |
+
key={index}
|
| 304 |
+
onClick={() => goToSlide(index)}
|
| 305 |
+
className={`w-2 h-2 rounded-full transition-all duration-300 ${
|
| 306 |
+
index === currentIndex
|
| 307 |
+
? "w-8 bg-primary"
|
| 308 |
+
: "bg-muted-foreground/30 hover:bg-muted-foreground/50"
|
| 309 |
+
}`}
|
| 310 |
+
aria-label={`Go to testimonial ${index + 1}`}
|
| 311 |
+
/>
|
| 312 |
+
))}
|
| 313 |
+
</div>
|
| 314 |
+
|
| 315 |
+
{/* Trust indicator */}
|
| 316 |
+
<div
|
| 317 |
+
className={`text-center mt-8 transition-all duration-1000 delay-400 ${
|
| 318 |
+
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10"
|
| 319 |
+
}`}
|
| 320 |
+
>
|
| 321 |
+
<p className="text-sm text-muted-foreground">
|
| 322 |
+
Trusted by <span className="text-primary font-semibold">50+</span> satisfied clients
|
| 323 |
+
</p>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
</section>
|
| 327 |
+
)
|
| 328 |
+
}
|
components/home/TrustIndicators.tsx
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { Building2, Sparkles, Star, TrendingUp, Users, Award } from "lucide-react"
|
| 4 |
+
import { useRef, useState, useEffect } from "react"
|
| 5 |
+
|
| 6 |
+
const companies = [
|
| 7 |
+
{ name: "Acme Corp", icon: Building2 },
|
| 8 |
+
{ name: "Global Tech", icon: TrendingUp },
|
| 9 |
+
{ name: "Nebula Inc", icon: Sparkles },
|
| 10 |
+
{ name: "CodeFlow", icon: Award },
|
| 11 |
+
{ name: "FutureSoft", icon: Users },
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
const stats = [
|
| 15 |
+
{ value: "50+", label: "Projects Delivered", icon: Star },
|
| 16 |
+
{ value: "98%", label: "Client Satisfaction", icon: TrendingUp },
|
| 17 |
+
{ value: "30+", label: "Happy Clients", icon: Users },
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
export function TrustIndicators() {
|
| 21 |
+
const sectionRef = useRef<HTMLElement>(null)
|
| 22 |
+
const [isVisible, setIsVisible] = useState(false)
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
const observer = new IntersectionObserver(
|
| 26 |
+
([entry]) => {
|
| 27 |
+
if (entry.isIntersecting) {
|
| 28 |
+
setIsVisible(true)
|
| 29 |
+
}
|
| 30 |
+
},
|
| 31 |
+
{ threshold: 0.2 }
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
if (sectionRef.current) {
|
| 35 |
+
observer.observe(sectionRef.current)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return () => observer.disconnect()
|
| 39 |
+
}, [])
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<section ref={sectionRef} className="py-14 bg-background relative overflow-hidden" aria-label="Trusted by">
|
| 43 |
+
{/* Background elements */}
|
| 44 |
+
<div className="absolute inset-0 gradient-mesh opacity-10" />
|
| 45 |
+
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[200px] bg-primary/5 rounded-full blur-3xl" />
|
| 46 |
+
|
| 47 |
+
<div className="container relative z-10">
|
| 48 |
+
{/* Header */}
|
| 49 |
+
<div
|
| 50 |
+
className={`text-center mb-12 transition-all duration-700 ${
|
| 51 |
+
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
|
| 52 |
+
}`}
|
| 53 |
+
>
|
| 54 |
+
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass-card">
|
| 55 |
+
<Sparkles className="w-4 h-4 text-primary animate-pulse" suppressHydrationWarning />
|
| 56 |
+
<span className="text-sm font-medium text-muted-foreground">Trusted by innovative companies</span>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
{/* Animated company logos - Infinite scroll effect */}
|
| 61 |
+
<div className="relative overflow-hidden mb-12">
|
| 62 |
+
{/* Gradient masks */}
|
| 63 |
+
<div className="absolute left-0 top-0 bottom-0 w-20 bg-gradient-to-r from-background to-transparent z-10 pointer-events-none" />
|
| 64 |
+
<div className="absolute right-0 top-0 bottom-0 w-20 bg-gradient-to-l from-background to-transparent z-10 pointer-events-none" />
|
| 65 |
+
|
| 66 |
+
{/* Scrolling container */}
|
| 67 |
+
<div className="flex gap-8 animate-scroll">
|
| 68 |
+
{[...companies, ...companies].map((company, i) => (
|
| 69 |
+
<div
|
| 70 |
+
key={`${company.name}-${i}`}
|
| 71 |
+
className="group flex items-center gap-3 text-muted-foreground hover:text-primary transition-all duration-300 cursor-default glass-card px-6 py-3 rounded-2xl shrink-0 hover:scale-105 hover:shadow-lg"
|
| 72 |
+
>
|
| 73 |
+
<company.icon
|
| 74 |
+
className="h-5 w-5 group-hover:scale-110 group-hover:rotate-12 transition-transform duration-300"
|
| 75 |
+
suppressHydrationWarning
|
| 76 |
+
/>
|
| 77 |
+
<span className="text-lg font-bold tracking-tight">{company.name}</span>
|
| 78 |
+
</div>
|
| 79 |
+
))}
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
{/* Stats row */}
|
| 84 |
+
<div
|
| 85 |
+
className={`flex flex-wrap items-center justify-center gap-8 md:gap-16 transition-all duration-700 delay-200 ${
|
| 86 |
+
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
|
| 87 |
+
}`}
|
| 88 |
+
>
|
| 89 |
+
{stats.map((stat, i) => (
|
| 90 |
+
<div
|
| 91 |
+
key={stat.label}
|
| 92 |
+
className="group text-center cursor-default"
|
| 93 |
+
style={{ transitionDelay: `${i * 100}ms` }}
|
| 94 |
+
>
|
| 95 |
+
<div className="flex items-center gap-2 mb-1">
|
| 96 |
+
<stat.icon className="w-4 h-4 text-primary opacity-50 group-hover:opacity-100 group-hover:scale-110 transition-all duration-300" suppressHydrationWarning />
|
| 97 |
+
<span className="text-2xl md:text-3xl font-bold gradient-text group-hover:scale-105 transition-transform duration-300 inline-block">
|
| 98 |
+
{stat.value}
|
| 99 |
+
</span>
|
| 100 |
+
</div>
|
| 101 |
+
<span className="text-xs md:text-sm text-muted-foreground group-hover:text-foreground transition-colors duration-300">
|
| 102 |
+
{stat.label}
|
| 103 |
+
</span>
|
| 104 |
+
</div>
|
| 105 |
+
))}
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
{/* CSS for scroll animation */}
|
| 110 |
+
<style jsx>{`
|
| 111 |
+
@keyframes scroll {
|
| 112 |
+
0% {
|
| 113 |
+
transform: translateX(0);
|
| 114 |
+
}
|
| 115 |
+
100% {
|
| 116 |
+
transform: translateX(-50%);
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
.animate-scroll {
|
| 120 |
+
animation: scroll 30s linear infinite;
|
| 121 |
+
}
|
| 122 |
+
.animate-scroll:hover {
|
| 123 |
+
animation-play-state: paused;
|
| 124 |
+
}
|
| 125 |
+
`}</style>
|
| 126 |
+
</section>
|
| 127 |
+
)
|
| 128 |
+
}
|
components/layout/Footer.tsx
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import Link from "next/link"
|
| 4 |
+
import { Button } from "@/components/ui/button"
|
| 5 |
+
import { Input } from "@/components/ui/input"
|
| 6 |
+
import { Github, Linkedin, Mail, ArrowRight, Sparkles, Heart } from "lucide-react"
|
| 7 |
+
import { useRef, useState, useEffect } from "react"
|
| 8 |
+
|
| 9 |
+
const footerLinks = [
|
| 10 |
+
{
|
| 11 |
+
title: "Services",
|
| 12 |
+
links: [
|
| 13 |
+
{ name: "Web Development", href: "/services" },
|
| 14 |
+
{ name: "App Development", href: "/services" },
|
| 15 |
+
{ name: "UI/UX Design", href: "/services" },
|
| 16 |
+
{ name: "SEO Optimization", href: "/services" },
|
| 17 |
+
],
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
title: "Company",
|
| 21 |
+
links: [
|
| 22 |
+
{ name: "About Us", href: "/about" },
|
| 23 |
+
{ name: "Portfolio", href: "/portfolio" },
|
| 24 |
+
{ name: "Blog", href: "/blog" },
|
| 25 |
+
{ name: "Contact", href: "/contact" },
|
| 26 |
+
],
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
title: "Legal",
|
| 30 |
+
links: [
|
| 31 |
+
{ name: "Privacy Policy", href: "/privacy" },
|
| 32 |
+
{ name: "Terms of Service", href: "/terms" },
|
| 33 |
+
{ name: "Cookie Policy", href: "/cookies" },
|
| 34 |
+
],
|
| 35 |
+
},
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
const socialLinks = [
|
| 39 |
+
{ icon: Github, href: "#", label: "GitHub", color: "hover:text-white" },
|
| 40 |
+
{ icon: Linkedin, href: "#", label: "LinkedIn", color: "hover:text-blue-400" },
|
| 41 |
+
{ icon: Mail, href: "mailto:hello@nexaflow.dev", label: "Email", color: "hover:text-primary" },
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
export function Footer() {
|
| 45 |
+
const [email, setEmail] = useState("")
|
| 46 |
+
const [isSubscribed, setIsSubscribed] = useState(false)
|
| 47 |
+
const sectionRef = useRef<HTMLElement>(null)
|
| 48 |
+
const [isVisible, setIsVisible] = useState(false)
|
| 49 |
+
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
const observer = new IntersectionObserver(
|
| 52 |
+
([entry]) => {
|
| 53 |
+
if (entry.isIntersecting) {
|
| 54 |
+
setIsVisible(true)
|
| 55 |
+
}
|
| 56 |
+
},
|
| 57 |
+
{ threshold: 0.1 }
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
if (sectionRef.current) {
|
| 61 |
+
observer.observe(sectionRef.current)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return () => observer.disconnect()
|
| 65 |
+
}, [])
|
| 66 |
+
|
| 67 |
+
const handleSubscribe = (e: React.FormEvent) => {
|
| 68 |
+
e.preventDefault()
|
| 69 |
+
if (email) {
|
| 70 |
+
setIsSubscribed(true)
|
| 71 |
+
setEmail("")
|
| 72 |
+
setTimeout(() => setIsSubscribed(false), 3000)
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return (
|
| 77 |
+
<footer ref={sectionRef} className="w-full bg-muted/20 relative overflow-hidden">
|
| 78 |
+
{/* Background decorations */}
|
| 79 |
+
<div className="absolute inset-0 gradient-mesh opacity-10" />
|
| 80 |
+
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary/10 rounded-full blur-3xl animate-float" style={{ animationDelay: '0s' }} />
|
| 81 |
+
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-accent/10 rounded-full blur-3xl animate-float" style={{ animationDelay: '5s' }} />
|
| 82 |
+
|
| 83 |
+
{/* Animated grid */}
|
| 84 |
+
<div className="absolute inset-0 opacity-5">
|
| 85 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,hsl(var(--primary))_1px,transparent_1px)] bg-size-[32px_32px]" />
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div className={`container py-14 md:py-16 lg:py-20 relative z-10 transition-all duration-1000 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`}>
|
| 89 |
+
<div className="grid grid-cols-1 gap-10 md:grid-cols-2 lg:grid-cols-5">
|
| 90 |
+
{/* Brand column */}
|
| 91 |
+
<div className="flex flex-col gap-4 lg:col-span-2 lg:pr-8">
|
| 92 |
+
<Link href="/" className="text-xl font-bold tracking-tight group inline-flex items-center gap-2">
|
| 93 |
+
<span className="group-hover:scale-105 transition-transform inline-block">
|
| 94 |
+
Nexa<span className="gradient-text">Flow</span>
|
| 95 |
+
</span>
|
| 96 |
+
<Sparkles className="w-4 h-4 text-primary opacity-0 group-hover:opacity-100 group-hover:rotate-12 transition-all duration-300" suppressHydrationWarning />
|
| 97 |
+
</Link>
|
| 98 |
+
<p className="text-sm text-muted-foreground leading-relaxed max-w-xs">
|
| 99 |
+
Building digital experiences that matter. We partner with forward-thinking companies to create impactful digital products.
|
| 100 |
+
</p>
|
| 101 |
+
|
| 102 |
+
{/* Social icons */}
|
| 103 |
+
<div className="flex gap-3 mt-2">
|
| 104 |
+
{socialLinks.map((social) => (
|
| 105 |
+
<Link
|
| 106 |
+
key={social.label}
|
| 107 |
+
href={social.href}
|
| 108 |
+
className={`flex h-10 w-10 items-center justify-center rounded-xl glass-card text-muted-foreground hover:scale-110 transition-all duration-300 group ${social.color}`}
|
| 109 |
+
aria-label={social.label}
|
| 110 |
+
>
|
| 111 |
+
<social.icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" suppressHydrationWarning />
|
| 112 |
+
</Link>
|
| 113 |
+
))}
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
{/* Link columns */}
|
| 118 |
+
{footerLinks.map((section, i) => (
|
| 119 |
+
<div
|
| 120 |
+
key={section.title}
|
| 121 |
+
className="flex flex-col gap-4"
|
| 122 |
+
style={{ transitionDelay: `${i * 100}ms` }}
|
| 123 |
+
>
|
| 124 |
+
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
| 125 |
+
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
| 126 |
+
{section.title}
|
| 127 |
+
</h3>
|
| 128 |
+
<ul className="flex flex-col gap-3">
|
| 129 |
+
{section.links.map((link) => (
|
| 130 |
+
<li key={link.name}>
|
| 131 |
+
<Link
|
| 132 |
+
href={link.href}
|
| 133 |
+
className="text-sm text-muted-foreground hover:text-primary transition-colors duration-200 interactive-underline inline-block"
|
| 134 |
+
>
|
| 135 |
+
{link.name}
|
| 136 |
+
</Link>
|
| 137 |
+
</li>
|
| 138 |
+
))}
|
| 139 |
+
</ul>
|
| 140 |
+
</div>
|
| 141 |
+
))}
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
{/* Newsletter signup */}
|
| 145 |
+
<div className="mt-12 relative">
|
| 146 |
+
{/* Animated border */}
|
| 147 |
+
<div className="absolute -inset-0.5 rounded-3xl animated-border opacity-30" />
|
| 148 |
+
|
| 149 |
+
<div className="relative glass-card rounded-3xl p-6 md:p-8 overflow-hidden">
|
| 150 |
+
{/* Inner glow */}
|
| 151 |
+
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
|
| 152 |
+
|
| 153 |
+
<div className="relative z-10 flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
|
| 154 |
+
<div>
|
| 155 |
+
<h3 className="text-lg font-semibold mb-2 flex items-center gap-2">
|
| 156 |
+
<Mail className="w-5 h-5 text-primary" suppressHydrationWarning />
|
| 157 |
+
Stay in the loop
|
| 158 |
+
</h3>
|
| 159 |
+
<p className="text-sm text-muted-foreground">Get the latest insights on web development and design delivered to your inbox.</p>
|
| 160 |
+
</div>
|
| 161 |
+
<form className="flex w-full md:w-auto gap-3" onSubmit={handleSubscribe}>
|
| 162 |
+
<Input
|
| 163 |
+
type="email"
|
| 164 |
+
placeholder="you@example.com"
|
| 165 |
+
value={email}
|
| 166 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 167 |
+
className="w-full md:w-64 glass-card border-0 focus:ring-2 focus:ring-primary/50"
|
| 168 |
+
/>
|
| 169 |
+
<Button type="submit" className="shine-effect group">
|
| 170 |
+
{isSubscribed ? (
|
| 171 |
+
<span className="flex items-center gap-2">
|
| 172 |
+
<Sparkles className="w-4 h-4" suppressHydrationWarning />
|
| 173 |
+
Subscribed!
|
| 174 |
+
</span>
|
| 175 |
+
) : (
|
| 176 |
+
<span className="flex items-center">
|
| 177 |
+
Subscribe
|
| 178 |
+
<ArrowRight className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform" suppressHydrationWarning />
|
| 179 |
+
</span>
|
| 180 |
+
)}
|
| 181 |
+
</Button>
|
| 182 |
+
</form>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
{/* Shine effect */}
|
| 186 |
+
<div className="absolute inset-0 shine-effect pointer-events-none rounded-3xl" />
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
{/* Bottom bar */}
|
| 191 |
+
<div className="mt-14 border-t border-border/50 pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
|
| 192 |
+
<p className="text-sm text-muted-foreground">
|
| 193 |
+
© {new Date().getFullYear()} NexaFlow. All rights reserved.
|
| 194 |
+
</p>
|
| 195 |
+
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
| 196 |
+
Designed with{" "}
|
| 197 |
+
<Heart className="w-4 h-4 text-red-500 fill-red-500 animate-pulse" suppressHydrationWarning />
|
| 198 |
+
{" "}by{" "}
|
| 199 |
+
<span className="text-primary font-medium">NexaFlow</span>
|
| 200 |
+
</p>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
</footer>
|
| 204 |
+
)
|
| 205 |
+
}
|
components/layout/Header.tsx
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import Link from "next/link"
|
| 5 |
+
import { usePathname } from "next/navigation"
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
| 9 |
+
import { Menu, Sparkles } from "lucide-react"
|
| 10 |
+
import { useSession, signOut } from "next-auth/react"
|
| 11 |
+
import { ModeToggle } from "@/components/mode-toggle"
|
| 12 |
+
|
| 13 |
+
const navItems = [
|
| 14 |
+
{ name: "Services", href: "/services" },
|
| 15 |
+
{ name: "Portfolio", href: "/portfolio" },
|
| 16 |
+
{ name: "Blog", href: "/blog" },
|
| 17 |
+
{ name: "About", href: "/about" },
|
| 18 |
+
{ name: "Contact", href: "/contact" },
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
function AuthButtons({ mobile, onAction }: { mobile?: boolean; onAction?: () => void }) {
|
| 22 |
+
const { data: session } = useSession()
|
| 23 |
+
|
| 24 |
+
if (session) {
|
| 25 |
+
return (
|
| 26 |
+
<div className={cn("flex gap-2", mobile ? "flex-col" : "items-center")}>
|
| 27 |
+
<span className="text-sm text-muted-foreground truncate max-w-[140px]">
|
| 28 |
+
{session.user?.email}
|
| 29 |
+
</span>
|
| 30 |
+
<Button
|
| 31 |
+
variant="outline"
|
| 32 |
+
size="sm"
|
| 33 |
+
className="glass-card hover:bg-primary/10"
|
| 34 |
+
onClick={() => {
|
| 35 |
+
signOut({ callbackUrl: "/admin/login" })
|
| 36 |
+
onAction?.()
|
| 37 |
+
}}
|
| 38 |
+
>
|
| 39 |
+
Logout
|
| 40 |
+
</Button>
|
| 41 |
+
<Button asChild variant="default" size="sm" onClick={onAction} className="shine-effect">
|
| 42 |
+
<Link href="/admin/dashboard">Dashboard</Link>
|
| 43 |
+
</Button>
|
| 44 |
+
</div>
|
| 45 |
+
)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className={cn("flex gap-2", mobile ? "flex-col" : "items-center")}>
|
| 50 |
+
<Button asChild size="sm" onClick={onAction} className="shine-effect">
|
| 51 |
+
<Link href="/quote">Get a Quote</Link>
|
| 52 |
+
</Button>
|
| 53 |
+
<Button asChild variant="ghost" size="sm" onClick={onAction} className="hover:bg-primary/10">
|
| 54 |
+
<Link href="/admin/login">Login</Link>
|
| 55 |
+
</Button>
|
| 56 |
+
</div>
|
| 57 |
+
)
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export function Header() {
|
| 61 |
+
const pathname = usePathname()
|
| 62 |
+
const [isOpen, setIsOpen] = React.useState(false)
|
| 63 |
+
const [scrolled, setScrolled] = React.useState(false)
|
| 64 |
+
|
| 65 |
+
React.useEffect(() => {
|
| 66 |
+
const handleScroll = () => {
|
| 67 |
+
setScrolled(window.scrollY > 20)
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
window.addEventListener('scroll', handleScroll)
|
| 71 |
+
return () => window.removeEventListener('scroll', handleScroll)
|
| 72 |
+
}, [])
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
<header
|
| 76 |
+
className={cn(
|
| 77 |
+
"sticky top-0 z-50 w-full transition-all duration-500 animate-fade-in-down",
|
| 78 |
+
scrolled
|
| 79 |
+
? "glass-card border-b border-border/20 shadow-lg"
|
| 80 |
+
: "bg-transparent"
|
| 81 |
+
)}
|
| 82 |
+
>
|
| 83 |
+
<div className="container flex h-16 items-center justify-between">
|
| 84 |
+
{/* Logo */}
|
| 85 |
+
<Link href="/" className="flex items-center gap-2 group">
|
| 86 |
+
<div className="relative">
|
| 87 |
+
<span className="text-xl font-bold tracking-tight transition-all duration-300 group-hover:scale-105 inline-block">
|
| 88 |
+
Nexa<span className="gradient-text">Flow</span>
|
| 89 |
+
</span>
|
| 90 |
+
{/* Animated underline on hover */}
|
| 91 |
+
<div className="absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent group-hover:w-full transition-all duration-300 rounded-full" />
|
| 92 |
+
</div>
|
| 93 |
+
<Sparkles className="w-4 h-4 text-primary opacity-0 group-hover:opacity-100 group-hover:rotate-12 transition-all duration-300" suppressHydrationWarning />
|
| 94 |
+
</Link>
|
| 95 |
+
|
| 96 |
+
{/* Desktop Navigation */}
|
| 97 |
+
<nav className="hidden md:flex items-center gap-1">
|
| 98 |
+
{navItems.map((item) => {
|
| 99 |
+
const isActive = pathname === item.href
|
| 100 |
+
return (
|
| 101 |
+
<Link
|
| 102 |
+
key={item.href}
|
| 103 |
+
href={item.href}
|
| 104 |
+
className={cn(
|
| 105 |
+
"relative px-5 py-2.5 text-sm font-semibold rounded-2xl transition-all duration-300 group",
|
| 106 |
+
isActive
|
| 107 |
+
? "text-primary glass-card"
|
| 108 |
+
: "text-muted-foreground hover:text-primary hover:bg-primary/5"
|
| 109 |
+
)}
|
| 110 |
+
>
|
| 111 |
+
<span className="relative z-10">{item.name}</span>
|
| 112 |
+
{/* Active indicator */}
|
| 113 |
+
{isActive && (
|
| 114 |
+
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary animate-pulse" />
|
| 115 |
+
)}
|
| 116 |
+
{/* Hover effect */}
|
| 117 |
+
<div className={cn(
|
| 118 |
+
"absolute inset-0 rounded-2xl bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300",
|
| 119 |
+
isActive && "opacity-100"
|
| 120 |
+
)} />
|
| 121 |
+
</Link>
|
| 122 |
+
)
|
| 123 |
+
})}
|
| 124 |
+
</nav>
|
| 125 |
+
|
| 126 |
+
{/* Desktop Actions */}
|
| 127 |
+
<div className="flex items-center gap-3">
|
| 128 |
+
<div className="hidden md:flex items-center gap-2">
|
| 129 |
+
<ModeToggle />
|
| 130 |
+
<AuthButtons />
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
{/* Mobile Menu */}
|
| 134 |
+
<div className="md:hidden flex items-center gap-2">
|
| 135 |
+
<ModeToggle />
|
| 136 |
+
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
| 137 |
+
<SheetTrigger asChild>
|
| 138 |
+
<Button variant="ghost" size="icon" className="glass-card hover:bg-primary/10">
|
| 139 |
+
<Menu className="h-5 w-5" suppressHydrationWarning />
|
| 140 |
+
<span className="sr-only">Toggle menu</span>
|
| 141 |
+
</Button>
|
| 142 |
+
</SheetTrigger>
|
| 143 |
+
<SheetContent side="right" className="w-80 max-w-[80vw] glass-card border-l border-border/20 p-0">
|
| 144 |
+
<div className="flex flex-col gap-6 p-6">
|
| 145 |
+
<Link
|
| 146 |
+
href="/"
|
| 147 |
+
onClick={() => setIsOpen(false)}
|
| 148 |
+
className="text-xl font-bold hover:text-primary transition-colors flex items-center gap-2 group"
|
| 149 |
+
>
|
| 150 |
+
Nexa<span className="gradient-text">Flow</span>
|
| 151 |
+
<Sparkles className="w-4 h-4 text-primary opacity-0 group-hover:opacity-100 transition-opacity" suppressHydrationWarning />
|
| 152 |
+
</Link>
|
| 153 |
+
<nav className="flex flex-col gap-2">
|
| 154 |
+
{navItems.map((item, i) => {
|
| 155 |
+
const isActive = pathname === item.href
|
| 156 |
+
return (
|
| 157 |
+
<Link
|
| 158 |
+
key={item.href}
|
| 159 |
+
href={item.href}
|
| 160 |
+
onClick={() => setIsOpen(false)}
|
| 161 |
+
className={cn(
|
| 162 |
+
"px-4 py-3 text-base font-medium rounded-xl transition-all duration-300 animate-fade-in-up",
|
| 163 |
+
isActive
|
| 164 |
+
? "text-primary glass-card"
|
| 165 |
+
: "text-muted-foreground hover:text-primary hover:bg-primary/5"
|
| 166 |
+
)}
|
| 167 |
+
style={{ animationDelay: `${i * 0.05}s` }}
|
| 168 |
+
>
|
| 169 |
+
{item.name}
|
| 170 |
+
</Link>
|
| 171 |
+
)
|
| 172 |
+
})}
|
| 173 |
+
</nav>
|
| 174 |
+
<div className="pt-4 border-t border-border/50 animate-fade-in-up animate-delay-3">
|
| 175 |
+
<AuthButtons mobile onAction={() => setIsOpen(false)} />
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</SheetContent>
|
| 179 |
+
</Sheet>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
</header>
|
| 184 |
+
)
|
| 185 |
+
}
|
components/mode-toggle.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Moon, Sun } from "lucide-react"
|
| 5 |
+
import { useTheme } from "next-themes"
|
| 6 |
+
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import {
|
| 9 |
+
DropdownMenu,
|
| 10 |
+
DropdownMenuContent,
|
| 11 |
+
DropdownMenuItem,
|
| 12 |
+
DropdownMenuTrigger,
|
| 13 |
+
} from "@/components/ui/dropdown-menu"
|
| 14 |
+
|
| 15 |
+
export function ModeToggle() {
|
| 16 |
+
const { setTheme } = useTheme()
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<DropdownMenu>
|
| 20 |
+
<DropdownMenuTrigger asChild>
|
| 21 |
+
<Button variant="outline" size="icon">
|
| 22 |
+
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" suppressHydrationWarning />
|
| 23 |
+
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" suppressHydrationWarning />
|
| 24 |
+
<span className="sr-only">Toggle theme</span>
|
| 25 |
+
</Button>
|
| 26 |
+
</DropdownMenuTrigger>
|
| 27 |
+
<DropdownMenuContent align="end">
|
| 28 |
+
<DropdownMenuItem onClick={() => setTheme("light")}>
|
| 29 |
+
Light
|
| 30 |
+
</DropdownMenuItem>
|
| 31 |
+
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
| 32 |
+
Dark
|
| 33 |
+
</DropdownMenuItem>
|
| 34 |
+
<DropdownMenuItem onClick={() => setTheme("system")}>
|
| 35 |
+
System
|
| 36 |
+
</DropdownMenuItem>
|
| 37 |
+
</DropdownMenuContent>
|
| 38 |
+
</DropdownMenu>
|
| 39 |
+
)
|
| 40 |
+
}
|
components/portfolio/ProjectGallery.tsx
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState } from "react"
|
| 4 |
+
import Link from "next/link"
|
| 5 |
+
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
export type Project = {
|
| 10 |
+
title: string
|
| 11 |
+
slug: string
|
| 12 |
+
category: string
|
| 13 |
+
description: string
|
| 14 |
+
tags: string[]
|
| 15 |
+
gradient?: string
|
| 16 |
+
emoji?: string
|
| 17 |
+
imageUrl?: string | null
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const categories = ["All", "Web", "Mobile", "Design", "SEO"]
|
| 21 |
+
|
| 22 |
+
export function ProjectGallery({ projects }: { projects: Project[] }) {
|
| 23 |
+
const [active, setActive] = useState("All")
|
| 24 |
+
|
| 25 |
+
const filtered = active === "All"
|
| 26 |
+
? projects
|
| 27 |
+
: projects.filter((p) => p.category === active)
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<div>
|
| 31 |
+
{/* Filter tabs */}
|
| 32 |
+
<div className="animate-fade-in-up flex flex-wrap justify-center gap-2 mb-12">
|
| 33 |
+
{categories.map((cat) => (
|
| 34 |
+
<Button
|
| 35 |
+
key={cat}
|
| 36 |
+
variant={active === cat ? "default" : "outline"}
|
| 37 |
+
size="sm"
|
| 38 |
+
onClick={() => setActive(cat)}
|
| 39 |
+
className="rounded-full"
|
| 40 |
+
>
|
| 41 |
+
{cat}
|
| 42 |
+
</Button>
|
| 43 |
+
))}
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
{/* Project grid */}
|
| 47 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
| 48 |
+
{filtered.map((project, i) => (
|
| 49 |
+
<Link key={project.slug} href={`/portfolio/${project.slug}`}>
|
| 50 |
+
<article
|
| 51 |
+
className="animate-fade-in-up h-full"
|
| 52 |
+
style={{ animationDelay: `${i * 0.06}s` }}
|
| 53 |
+
>
|
| 54 |
+
<Card className="group h-full overflow-hidden border hover:border-primary/20 hover:shadow-xl hover:shadow-primary/5 transition-all duration-500 cursor-pointer">
|
| 55 |
+
{/* Thumbnail */}
|
| 56 |
+
<div className="relative aspect-[16/10] bg-muted overflow-hidden flex items-center justify-center">
|
| 57 |
+
{project.imageUrl ? (
|
| 58 |
+
<img
|
| 59 |
+
src={project.imageUrl.startsWith('http') || project.imageUrl.startsWith('/') ? project.imageUrl : `/${project.imageUrl}`}
|
| 60 |
+
alt={project.title}
|
| 61 |
+
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
| 62 |
+
/>
|
| 63 |
+
) : (
|
| 64 |
+
<div className={`w-full h-full bg-gradient-to-br ${project.gradient || 'from-gray-800 to-gray-600'} flex items-center justify-center`}>
|
| 65 |
+
<span
|
| 66 |
+
className="text-6xl group-hover:scale-110 transition-transform duration-500"
|
| 67 |
+
role="img"
|
| 68 |
+
aria-label={project.title}
|
| 69 |
+
>
|
| 70 |
+
{project.emoji || '🚀'}
|
| 71 |
+
</span>
|
| 72 |
+
</div>
|
| 73 |
+
)}
|
| 74 |
+
</div>
|
| 75 |
+
<CardHeader>
|
| 76 |
+
<p className="text-overline text-primary mb-2">{project.category}</p>
|
| 77 |
+
<h3 className="text-xl font-semibold leading-none">{project.title}</h3>
|
| 78 |
+
</CardHeader>
|
| 79 |
+
<CardContent>
|
| 80 |
+
<p className="text-caption leading-relaxed mb-4">{project.description}</p>
|
| 81 |
+
{/* Technology tags (SF-015) */}
|
| 82 |
+
<div className="flex flex-wrap gap-2">
|
| 83 |
+
{project.tags.map((tag) => (
|
| 84 |
+
<span
|
| 85 |
+
key={tag}
|
| 86 |
+
className="bg-secondary text-secondary-foreground px-3 py-1 rounded-full text-xs font-medium"
|
| 87 |
+
>
|
| 88 |
+
{tag}
|
| 89 |
+
</span>
|
| 90 |
+
))}
|
| 91 |
+
</div>
|
| 92 |
+
</CardContent>
|
| 93 |
+
</Card>
|
| 94 |
+
</article>
|
| 95 |
+
</Link>
|
| 96 |
+
))}
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
{filtered.length === 0 && (
|
| 100 |
+
<p className="text-center text-muted-foreground py-12">
|
| 101 |
+
No projects in this category yet. Check back soon!
|
| 102 |
+
</p>
|
| 103 |
+
)}
|
| 104 |
+
</div>
|
| 105 |
+
)
|
| 106 |
+
}
|
components/services/AddOnServices.tsx
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from "next/link"
|
| 2 |
+
import { Button } from "@/components/ui/button"
|
| 3 |
+
import { Shield, BarChart3, Wrench, Globe, ArrowRight } from "lucide-react"
|
| 4 |
+
|
| 5 |
+
const addons = [
|
| 6 |
+
{
|
| 7 |
+
title: "Maintenance Plan",
|
| 8 |
+
description: "Monthly security updates, performance monitoring, and content changes.",
|
| 9 |
+
price: "From $299/mo",
|
| 10 |
+
icon: Wrench,
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
title: "SEO Audit & Strategy",
|
| 14 |
+
description: "Comprehensive audit, keyword research, and a 3-month optimization roadmap.",
|
| 15 |
+
price: "From $999",
|
| 16 |
+
icon: BarChart3,
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
title: "Security Hardening",
|
| 20 |
+
description: "Advanced security audits, penetration testing, and compliance setup.",
|
| 21 |
+
price: "From $1,499",
|
| 22 |
+
icon: Shield,
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
title: "Analytics & Tracking",
|
| 26 |
+
description: "Google Analytics, heatmaps, conversion tracking, and monthly reports.",
|
| 27 |
+
price: "From $499",
|
| 28 |
+
icon: Globe,
|
| 29 |
+
},
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
export function AddOnServices() {
|
| 33 |
+
return (
|
| 34 |
+
<section className="py-24 bg-background" id="addons">
|
| 35 |
+
<div className="container">
|
| 36 |
+
<div className="animate-fade-in-up text-center mb-16">
|
| 37 |
+
<p className="text-overline text-primary mb-3">Extras</p>
|
| 38 |
+
<h2 className="sm:text-4xl mb-4">Add-on Services</h2>
|
| 39 |
+
<p className="text-body text-muted-foreground max-w-2xl mx-auto">
|
| 40 |
+
Enhance your project with these supplementary services.
|
| 41 |
+
</p>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 45 |
+
{addons.map((addon, i) => (
|
| 46 |
+
<div
|
| 47 |
+
key={addon.title}
|
| 48 |
+
className="animate-fade-in-up group rounded-2xl border bg-card p-6 hover:border-primary/20 hover:shadow-lg transition-all duration-500"
|
| 49 |
+
style={{ animationDelay: `${i * 0.1}s` }}
|
| 50 |
+
>
|
| 51 |
+
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 text-primary mb-4 group-hover:bg-primary group-hover:text-primary-foreground transition-colors duration-300">
|
| 52 |
+
<addon.icon className="w-6 h-6" suppressHydrationWarning />
|
| 53 |
+
</div>
|
| 54 |
+
<h3 className="font-bold mb-2">{addon.title}</h3>
|
| 55 |
+
<p className="text-caption leading-relaxed mb-4">{addon.description}</p>
|
| 56 |
+
<p className="text-sm font-semibold text-primary mb-4">{addon.price}</p>
|
| 57 |
+
<Button asChild variant="outline" size="sm" className="w-full group/btn">
|
| 58 |
+
<Link href="/quote">
|
| 59 |
+
Add to Quote
|
| 60 |
+
<ArrowRight className="ml-2 h-3 w-3 transition-transform group-hover/btn:translate-x-1" suppressHydrationWarning />
|
| 61 |
+
</Link>
|
| 62 |
+
</Button>
|
| 63 |
+
</div>
|
| 64 |
+
))}
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
</section>
|
| 68 |
+
)
|
| 69 |
+
}
|
components/services/FAQSection.tsx
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import {
|
| 4 |
+
Accordion,
|
| 5 |
+
AccordionContent,
|
| 6 |
+
AccordionItem,
|
| 7 |
+
AccordionTrigger,
|
| 8 |
+
} from "@/components/ui/accordion"
|
| 9 |
+
|
| 10 |
+
const faqCategories = [
|
| 11 |
+
{
|
| 12 |
+
category: "Pricing & Packages",
|
| 13 |
+
questions: [
|
| 14 |
+
{
|
| 15 |
+
q: "What's included in each package?",
|
| 16 |
+
a: "Each package includes a dedicated project manager, custom design, development, quality assurance, and a warranty period. The Starter package covers up to 5 pages, Professional up to 15 pages with CMS, and Enterprise offers unlimited scope with custom integrations.",
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
q: "Are there any hidden fees?",
|
| 20 |
+
a: "No. We provide a detailed project quote upfront and any scope changes are discussed and approved before billing. The only additional costs would be third-party services like hosting, domain names, or premium API subscriptions.",
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
q: "Do you offer payment plans?",
|
| 24 |
+
a: "Yes! We typically split payments into milestones: 30% upfront, 40% at design approval, and 30% at launch. For Enterprise projects, we can arrange custom payment schedules.",
|
| 25 |
+
},
|
| 26 |
+
],
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
category: "Process & Timeline",
|
| 30 |
+
questions: [
|
| 31 |
+
{
|
| 32 |
+
q: "How long does a typical project take?",
|
| 33 |
+
a: "Starter projects typically take 3-4 weeks, Professional projects 6-8 weeks, and Enterprise projects 10-16+ weeks depending on complexity. We'll provide a detailed timeline during the discovery phase.",
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
q: "What does the development process look like?",
|
| 37 |
+
a: "Our process follows four phases: Discovery (requirements & strategy), Design (wireframes & mockups), Development (coding & integration), and Deployment (testing, launch & handoff). You'll have visibility and input at every stage.",
|
| 38 |
+
},
|
| 39 |
+
],
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
category: "Technical",
|
| 43 |
+
questions: [
|
| 44 |
+
{
|
| 45 |
+
q: "What technologies do you use?",
|
| 46 |
+
a: "We primarily build with Next.js, React, TypeScript, and Tailwind CSS for frontend; Node.js with Express or Next.js API routes for backend; and PostgreSQL or MongoDB for databases. For mobile, we use React Native or Flutter.",
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
q: "Will my website be SEO-friendly?",
|
| 50 |
+
a: "Absolutely. We build with SEO best practices from day one — semantic HTML, server-side rendering, optimized images, structured data, meta tags, sitemap generation, and fast page load times.",
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
q: "Can I update content myself after launch?",
|
| 54 |
+
a: "Yes! We integrate a content management system (CMS) so you can update text, images, blog posts, and more without any coding knowledge.",
|
| 55 |
+
},
|
| 56 |
+
],
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
category: "Support",
|
| 60 |
+
questions: [
|
| 61 |
+
{
|
| 62 |
+
q: "Do you offer ongoing support after launch?",
|
| 63 |
+
a: "Yes. All packages include a 30-day warranty post-launch. We also offer monthly maintenance plans that include security updates, performance monitoring, content updates, and priority support.",
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
q: "What if I need changes after the project is delivered?",
|
| 67 |
+
a: "Minor adjustments within the warranty period are covered at no extra cost. For larger changes or new features, we'll provide a quote and timeline. Many of our clients retain us on a monthly basis for ongoing improvements.",
|
| 68 |
+
},
|
| 69 |
+
],
|
| 70 |
+
},
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
export function FAQSection() {
|
| 74 |
+
return (
|
| 75 |
+
<section className="py-24 bg-muted/20" id="faq">
|
| 76 |
+
<div className="container max-w-3xl">
|
| 77 |
+
<div className="animate-fade-in-up text-center mb-16">
|
| 78 |
+
<p className="text-overline text-primary mb-3">FAQ</p>
|
| 79 |
+
<h2 className="sm:text-4xl mb-4">Frequently Asked Questions</h2>
|
| 80 |
+
<p className="text-body text-muted-foreground">
|
| 81 |
+
Got questions? We've got answers.
|
| 82 |
+
</p>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div className="space-y-8">
|
| 86 |
+
{faqCategories.map((cat, ci) => (
|
| 87 |
+
<div key={cat.category} className="animate-fade-in-up" style={{ animationDelay: `${ci * 0.1}s` }}>
|
| 88 |
+
<h3 className="font-bold mb-4">{cat.category}</h3>
|
| 89 |
+
<Accordion type="single" collapsible className="space-y-2">
|
| 90 |
+
{cat.questions.map((item, qi) => (
|
| 91 |
+
<AccordionItem
|
| 92 |
+
key={qi}
|
| 93 |
+
value={`${ci}-${qi}`}
|
| 94 |
+
className="rounded-xl border bg-card px-4 data-[state=open]:border-primary/20 transition-colors"
|
| 95 |
+
>
|
| 96 |
+
<AccordionTrigger className="text-sm font-medium hover:no-underline py-4">
|
| 97 |
+
{item.q}
|
| 98 |
+
</AccordionTrigger>
|
| 99 |
+
<AccordionContent className="text-sm text-muted-foreground leading-relaxed pb-4">
|
| 100 |
+
{item.a}
|
| 101 |
+
</AccordionContent>
|
| 102 |
+
</AccordionItem>
|
| 103 |
+
))}
|
| 104 |
+
</Accordion>
|
| 105 |
+
</div>
|
| 106 |
+
))}
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</section>
|
| 110 |
+
)
|
| 111 |
+
}
|
components/services/PackageComparison.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from "next/link"
|
| 2 |
+
import { Button } from "@/components/ui/button"
|
| 3 |
+
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
| 4 |
+
import { Check, X, ArrowRight, Sparkles, Star, Zap } from "lucide-react"
|
| 5 |
+
|
| 6 |
+
const tiers = [
|
| 7 |
+
{
|
| 8 |
+
name: "Starter",
|
| 9 |
+
tagline: "Perfect for small businesses",
|
| 10 |
+
price: "$2,999",
|
| 11 |
+
icon: Sparkles,
|
| 12 |
+
gradient: "from-blue-500 to-cyan-400",
|
| 13 |
+
popular: false,
|
| 14 |
+
features: [
|
| 15 |
+
{ name: "Custom design (up to 5 pages)", included: true },
|
| 16 |
+
{ name: "Responsive layout", included: true },
|
| 17 |
+
{ name: "Contact form", included: true },
|
| 18 |
+
{ name: "Basic SEO setup", included: true },
|
| 19 |
+
{ name: "1 round of revisions", included: true },
|
| 20 |
+
{ name: "CMS integration", included: false },
|
| 21 |
+
{ name: "E-commerce functionality", included: false },
|
| 22 |
+
{ name: "Priority support", included: false },
|
| 23 |
+
],
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
name: "Professional",
|
| 27 |
+
tagline: "Most popular for growing brands",
|
| 28 |
+
price: "$7,499",
|
| 29 |
+
icon: Star,
|
| 30 |
+
gradient: "from-primary to-blue-500",
|
| 31 |
+
popular: true,
|
| 32 |
+
features: [
|
| 33 |
+
{ name: "Custom design (up to 15 pages)", included: true },
|
| 34 |
+
{ name: "Responsive layout", included: true },
|
| 35 |
+
{ name: "Advanced forms & integrations", included: true },
|
| 36 |
+
{ name: "Full SEO optimization", included: true },
|
| 37 |
+
{ name: "3 rounds of revisions", included: true },
|
| 38 |
+
{ name: "CMS integration", included: true },
|
| 39 |
+
{ name: "E-commerce functionality", included: false },
|
| 40 |
+
{ name: "Priority support", included: true },
|
| 41 |
+
],
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
name: "Enterprise",
|
| 45 |
+
tagline: "For ambitious, large-scale projects",
|
| 46 |
+
price: "Custom",
|
| 47 |
+
icon: Zap,
|
| 48 |
+
gradient: "from-violet-600 to-purple-500",
|
| 49 |
+
popular: false,
|
| 50 |
+
features: [
|
| 51 |
+
{ name: "Unlimited pages & features", included: true },
|
| 52 |
+
{ name: "Responsive layout", included: true },
|
| 53 |
+
{ name: "Custom integrations & APIs", included: true },
|
| 54 |
+
{ name: "Full SEO optimization", included: true },
|
| 55 |
+
{ name: "Unlimited revisions", included: true },
|
| 56 |
+
{ name: "CMS integration", included: true },
|
| 57 |
+
{ name: "E-commerce functionality", included: true },
|
| 58 |
+
{ name: "Priority support", included: true },
|
| 59 |
+
],
|
| 60 |
+
},
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
export function PackageComparison() {
|
| 64 |
+
return (
|
| 65 |
+
<section className="py-24 bg-background" id="packages">
|
| 66 |
+
<div className="container">
|
| 67 |
+
<div className="animate-fade-in-up text-center mb-16">
|
| 68 |
+
<p className="text-overline text-primary mb-3">Pricing</p>
|
| 69 |
+
<h2 className="sm:text-4xl mb-4">Choose Your Package</h2>
|
| 70 |
+
<p className="text-body text-muted-foreground max-w-2xl mx-auto">
|
| 71 |
+
Transparent pricing with no hidden fees. Every package includes a dedicated project manager and quality assurance.
|
| 72 |
+
</p>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-start">
|
| 76 |
+
{tiers.map((tier, i) => (
|
| 77 |
+
<div key={tier.name} className="animate-fade-in-up" style={{ animationDelay: `${i * 0.1}s` }}>
|
| 78 |
+
<Card className={`relative h-full ${tier.popular ? "border-primary shadow-xl shadow-primary/10 scale-[1.02]" : "border"}`}>
|
| 79 |
+
{tier.popular && (
|
| 80 |
+
<div className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-primary px-4 py-1 text-xs font-semibold text-primary-foreground">
|
| 81 |
+
Most Popular
|
| 82 |
+
</div>
|
| 83 |
+
)}
|
| 84 |
+
<CardHeader className="text-center pb-2">
|
| 85 |
+
<div className={`w-12 h-12 mx-auto rounded-xl bg-gradient-to-br ${tier.gradient} flex items-center justify-center text-white shadow-lg mb-4`}>
|
| 86 |
+
<tier.icon className="w-6 h-6" suppressHydrationWarning />
|
| 87 |
+
</div>
|
| 88 |
+
<h3 className="text-xl font-bold">{tier.name}</h3>
|
| 89 |
+
<p className="text-caption">{tier.tagline}</p>
|
| 90 |
+
<div className="mt-4">
|
| 91 |
+
<span className="text-4xl font-extrabold tracking-tight">{tier.price}</span>
|
| 92 |
+
{tier.price !== "Custom" && <span className="text-caption ml-1">starting</span>}
|
| 93 |
+
</div>
|
| 94 |
+
</CardHeader>
|
| 95 |
+
<CardContent className="space-y-6">
|
| 96 |
+
<ul className="space-y-3">
|
| 97 |
+
{tier.features.map((feature) => (
|
| 98 |
+
<li key={feature.name} className="flex items-center gap-3 text-sm">
|
| 99 |
+
{feature.included ? (
|
| 100 |
+
<Check className="h-4 w-4 text-green-500 shrink-0" suppressHydrationWarning />
|
| 101 |
+
) : (
|
| 102 |
+
<X className="h-4 w-4 text-muted-foreground/40 shrink-0" suppressHydrationWarning />
|
| 103 |
+
)}
|
| 104 |
+
<span className={feature.included ? "" : "text-muted-foreground/60"}>
|
| 105 |
+
{feature.name}
|
| 106 |
+
</span>
|
| 107 |
+
</li>
|
| 108 |
+
))}
|
| 109 |
+
</ul>
|
| 110 |
+
<Button asChild className="w-full" variant={tier.popular ? "default" : "outline"} size="lg">
|
| 111 |
+
<Link href="/quote">
|
| 112 |
+
Get Started
|
| 113 |
+
<ArrowRight className="ml-2 h-4 w-4" suppressHydrationWarning />
|
| 114 |
+
</Link>
|
| 115 |
+
</Button>
|
| 116 |
+
</CardContent>
|
| 117 |
+
</Card>
|
| 118 |
+
</div>
|
| 119 |
+
))}
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</section>
|
| 123 |
+
)
|
| 124 |
+
}
|
components/services/ProcessTimeline.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Search, PenTool, Code, Rocket } from "lucide-react"
|
| 2 |
+
|
| 3 |
+
const steps = [
|
| 4 |
+
{
|
| 5 |
+
number: "01",
|
| 6 |
+
title: "Discovery",
|
| 7 |
+
description: "We dive deep into your business goals, target audience, and competition to build a solid project strategy.",
|
| 8 |
+
icon: Search,
|
| 9 |
+
gradient: "from-blue-500 to-cyan-400",
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
number: "02",
|
| 13 |
+
title: "Design",
|
| 14 |
+
description: "Our designers create wireframes and high-fidelity mockups that bring your vision to life with pixel-perfect precision.",
|
| 15 |
+
icon: PenTool,
|
| 16 |
+
gradient: "from-violet-500 to-purple-400",
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
number: "03",
|
| 20 |
+
title: "Develop",
|
| 21 |
+
description: "Our engineers build your product using modern tech stacks with clean code, performance, and scalability in mind.",
|
| 22 |
+
icon: Code,
|
| 23 |
+
gradient: "from-pink-500 to-rose-400",
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
number: "04",
|
| 27 |
+
title: "Deploy",
|
| 28 |
+
description: "We launch your project with rigorous QA testing, performance optimization, and ongoing support to ensure success.",
|
| 29 |
+
icon: Rocket,
|
| 30 |
+
gradient: "from-amber-500 to-orange-400",
|
| 31 |
+
},
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
export function ProcessTimeline() {
|
| 35 |
+
return (
|
| 36 |
+
<section className="py-24 bg-background" id="process">
|
| 37 |
+
<div className="container">
|
| 38 |
+
<div className="animate-fade-in-up text-center mb-16">
|
| 39 |
+
<p className="text-overline text-primary mb-3">Our Process</p>
|
| 40 |
+
<h2 className="sm:text-4xl mb-4">How We Work</h2>
|
| 41 |
+
<p className="text-body text-muted-foreground max-w-2xl mx-auto">
|
| 42 |
+
A proven 4-step process that takes your project from idea to launch.
|
| 43 |
+
</p>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
{/* Desktop: horizontal timeline */}
|
| 47 |
+
<div className="hidden md:grid md:grid-cols-4 gap-8 relative">
|
| 48 |
+
{/* Connecting line */}
|
| 49 |
+
<div className="absolute top-10 left-[12.5%] right-[12.5%] h-0.5 bg-border" aria-hidden="true" />
|
| 50 |
+
|
| 51 |
+
{steps.map((step, i) => (
|
| 52 |
+
<div key={step.number} className="animate-fade-in-up relative text-center" style={{ animationDelay: `${i * 0.12}s` }}>
|
| 53 |
+
<div className={`relative z-10 w-20 h-20 mx-auto rounded-2xl bg-gradient-to-br ${step.gradient} flex items-center justify-center text-white shadow-lg mb-6`}>
|
| 54 |
+
<step.icon className="w-8 h-8" suppressHydrationWarning />
|
| 55 |
+
</div>
|
| 56 |
+
<div className="text-xs font-bold text-primary mb-1">{step.number}</div>
|
| 57 |
+
<h3 className="font-bold mb-2">{step.title}</h3>
|
| 58 |
+
<p className="text-caption leading-relaxed">{step.description}</p>
|
| 59 |
+
</div>
|
| 60 |
+
))}
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
{/* Mobile: vertical timeline */}
|
| 64 |
+
<div className="md:hidden relative pl-12">
|
| 65 |
+
{/* Vertical line */}
|
| 66 |
+
<div className="absolute left-5 top-0 bottom-0 w-0.5 bg-border" aria-hidden="true" />
|
| 67 |
+
|
| 68 |
+
{steps.map((step, i) => (
|
| 69 |
+
<div key={step.number} className="animate-fade-in-up relative pb-12 last:pb-0" style={{ animationDelay: `${i * 0.12}s` }}>
|
| 70 |
+
<div className={`absolute left-[-1.75rem] z-10 w-10 h-10 rounded-xl bg-gradient-to-br ${step.gradient} flex items-center justify-center text-white shadow-lg`}>
|
| 71 |
+
<step.icon className="w-5 h-5" suppressHydrationWarning />
|
| 72 |
+
</div>
|
| 73 |
+
<div className="text-xs font-bold text-primary mb-1">{step.number}</div>
|
| 74 |
+
<h3 className="font-bold mb-2">{step.title}</h3>
|
| 75 |
+
<p className="text-caption leading-relaxed">{step.description}</p>
|
| 76 |
+
</div>
|
| 77 |
+
))}
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</section>
|
| 81 |
+
)
|
| 82 |
+
}
|
components/services/ServiceDescriptions.tsx
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
| 2 |
+
import { Code, Palette, Smartphone, LineChart, ArrowUpRight } from "lucide-react"
|
| 3 |
+
import Link from "next/link"
|
| 4 |
+
|
| 5 |
+
const services = [
|
| 6 |
+
{
|
| 7 |
+
title: "Web Development",
|
| 8 |
+
description: "We build fast, scalable web applications using Next.js, React, and Node.js. From marketing sites to complex SaaS platforms.",
|
| 9 |
+
icon: Code,
|
| 10 |
+
gradient: "from-blue-500 to-cyan-400",
|
| 11 |
+
deliverables: ["Custom Website", "Web Application", "API Development", "Database Design"],
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
title: "Mobile App Development",
|
| 15 |
+
description: "Cross-platform mobile apps for iOS and Android built with React Native and Flutter. Native performance, shared codebase.",
|
| 16 |
+
icon: Smartphone,
|
| 17 |
+
gradient: "from-violet-500 to-purple-400",
|
| 18 |
+
deliverables: ["iOS App", "Android App", "Cross-Platform", "App Store Deployment"],
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
title: "UI/UX Design",
|
| 22 |
+
description: "User-centric design that converts. We craft interfaces that are both beautiful and intuitive, backed by research and usability testing.",
|
| 23 |
+
icon: Palette,
|
| 24 |
+
gradient: "from-pink-500 to-rose-400",
|
| 25 |
+
deliverables: ["Wireframes", "Prototypes", "Design System", "Usability Testing"],
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
title: "SEO Optimization",
|
| 29 |
+
description: "Data-driven strategies to boost your search rankings, drive organic traffic, and increase visibility across all major search engines.",
|
| 30 |
+
icon: LineChart,
|
| 31 |
+
gradient: "from-amber-500 to-orange-400",
|
| 32 |
+
deliverables: ["Technical Audit", "Keyword Research", "On-Page SEO", "Analytics Setup"],
|
| 33 |
+
},
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
export function ServiceDescriptions() {
|
| 37 |
+
return (
|
| 38 |
+
<section className="py-24 bg-muted/20" id="services-detail">
|
| 39 |
+
<div className="container">
|
| 40 |
+
<div className="animate-fade-in-up text-center mb-16">
|
| 41 |
+
<p className="text-overline text-primary mb-3">What We Do</p>
|
| 42 |
+
<h2 className="sm:text-4xl mb-4">Our Services</h2>
|
| 43 |
+
<p className="text-body text-muted-foreground max-w-2xl mx-auto">
|
| 44 |
+
End-to-end digital solutions tailored to your business goals.
|
| 45 |
+
</p>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div className="grid gap-8 md:grid-cols-2">
|
| 49 |
+
{services.map((service, i) => (
|
| 50 |
+
<div key={service.title} className="animate-fade-in-up" style={{ animationDelay: `${i * 0.1}s` }}>
|
| 51 |
+
<Card className="group h-full border hover:border-primary/30 hover:shadow-xl hover:shadow-primary/5 transition-all duration-500">
|
| 52 |
+
<CardHeader>
|
| 53 |
+
<div className="flex items-start justify-between">
|
| 54 |
+
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${service.gradient} flex items-center justify-center text-white shadow-lg group-hover:scale-110 transition-transform duration-300`}>
|
| 55 |
+
<service.icon className="w-7 h-7" suppressHydrationWarning />
|
| 56 |
+
</div>
|
| 57 |
+
<Link
|
| 58 |
+
href="/quote"
|
| 59 |
+
className="opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
| 60 |
+
aria-label={`Request a quote for ${service.title}`}
|
| 61 |
+
>
|
| 62 |
+
<ArrowUpRight className="h-5 w-5 text-primary" suppressHydrationWarning />
|
| 63 |
+
</Link>
|
| 64 |
+
</div>
|
| 65 |
+
<h3 className="text-xl font-bold mt-4">{service.title}</h3>
|
| 66 |
+
</CardHeader>
|
| 67 |
+
<CardContent className="space-y-4">
|
| 68 |
+
<p className="text-body text-muted-foreground">{service.description}</p>
|
| 69 |
+
<div>
|
| 70 |
+
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">Key Deliverables</p>
|
| 71 |
+
<div className="flex flex-wrap gap-2">
|
| 72 |
+
{service.deliverables.map((d) => (
|
| 73 |
+
<span key={d} className="bg-secondary text-secondary-foreground px-3 py-1 rounded-full text-xs font-medium">
|
| 74 |
+
{d}
|
| 75 |
+
</span>
|
| 76 |
+
))}
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</CardContent>
|
| 80 |
+
</Card>
|
| 81 |
+
</div>
|
| 82 |
+
))}
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
</section>
|
| 86 |
+
)
|
| 87 |
+
}
|
components/theme-provider.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
| 5 |
+
|
| 6 |
+
export function ThemeProvider({
|
| 7 |
+
children,
|
| 8 |
+
...props
|
| 9 |
+
}: React.ComponentProps<typeof NextThemesProvider>) {
|
| 10 |
+
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
| 11 |
+
}
|
components/ui/accordion.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { ChevronDownIcon } from "lucide-react"
|
| 5 |
+
import { Accordion as AccordionPrimitive } from "radix-ui"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
function Accordion({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
| 12 |
+
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function AccordionItem({
|
| 16 |
+
className,
|
| 17 |
+
...props
|
| 18 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
| 19 |
+
return (
|
| 20 |
+
<AccordionPrimitive.Item
|
| 21 |
+
data-slot="accordion-item"
|
| 22 |
+
className={cn("border-b last:border-b-0", className)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function AccordionTrigger({
|
| 29 |
+
className,
|
| 30 |
+
children,
|
| 31 |
+
...props
|
| 32 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
| 33 |
+
return (
|
| 34 |
+
<AccordionPrimitive.Header className="flex">
|
| 35 |
+
<AccordionPrimitive.Trigger
|
| 36 |
+
data-slot="accordion-trigger"
|
| 37 |
+
className={cn(
|
| 38 |
+
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
| 39 |
+
className
|
| 40 |
+
)}
|
| 41 |
+
{...props}
|
| 42 |
+
>
|
| 43 |
+
{children}
|
| 44 |
+
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
| 45 |
+
</AccordionPrimitive.Trigger>
|
| 46 |
+
</AccordionPrimitive.Header>
|
| 47 |
+
)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function AccordionContent({
|
| 51 |
+
className,
|
| 52 |
+
children,
|
| 53 |
+
...props
|
| 54 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
| 55 |
+
return (
|
| 56 |
+
<AccordionPrimitive.Content
|
| 57 |
+
data-slot="accordion-content"
|
| 58 |
+
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
| 59 |
+
{...props}
|
| 60 |
+
>
|
| 61 |
+
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
| 62 |
+
</AccordionPrimitive.Content>
|
| 63 |
+
)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|