initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:21:47 +02:00
commit e7987e88a7
70 changed files with 13661 additions and 0 deletions

View file

@ -0,0 +1,122 @@
import { Link } from 'react-router-dom'
import { Code, Github, Linkedin, Twitter, Mail, MapPin, Phone } from 'lucide-react'
const footerLinks = {
services: [
{ label: 'Web Development', href: '#services' },
{ label: 'Backend & APIs', href: '#services' },
{ label: 'Cloud & DevOps', href: '#services' },
{ label: 'Architecture', href: '#services' },
{ label: 'Modernization', href: '#services' },
{ label: 'Advisory', href: '#services' },
],
company: [
{ label: 'About', href: '#experience' },
{ label: 'Projects', href: '#projects' },
{ label: 'Skills', href: '#skills' },
{ label: 'Contact', href: '#contact' },
],
legal: [
{ label: 'Privacy Policy', href: '/privacy' },
{ label: 'Terms of Service', href: '/terms' },
{ label: 'Cookie Policy', href: '/cookies' },
],
}
const socialLinks = [
{ icon: Github, href: 'https://github.com/urpi', label: 'GitHub' },
{ icon: Linkedin, href: 'https://linkedin.com/company/urpi', label: 'LinkedIn' },
{ icon: Twitter, href: 'https://twitter.com/urpi_be', label: 'Twitter' },
{ icon: Mail, href: 'mailto:contact@urpi.be', label: 'Email' },
]
export function Footer() {
return (
<footer className="bg-dark-900 text-dark-300" role="contentinfo">
<div className="container py-16 lg:py-24">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-12">
<div className="lg:col-span-2">
<Link to="/" className="flex items-center gap-2 text-xl font-display font-bold text-white mb-4" aria-label="URPI Home">
<Code className="h-8 w-8 text-brand-500" />
<span>URPI</span>
</Link>
<p className="text-dark-400 mb-6 max-w-xs">
Web application & backend development experts. Building scalable, maintainable software solutions with Go, Java, React, and cloud-native technologies.
</p>
<div className="flex gap-4">
{socialLinks.map(({ icon: Icon, href, label }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-dark-800 text-dark-400 hover:bg-brand-500/20 hover:text-brand-400 transition-all duration-200"
aria-label={label}
>
<Icon className="h-5 w-5" />
</a>
))}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-white uppercase tracking-wider mb-4">Services</h3>
<ul className="space-y-3">
{footerLinks.services.map((link) => (
<li key={link.label}>
<Link to={link.href} className="text-dark-400 hover:text-brand-400 transition-colors">
{link.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-white uppercase tracking-wider mb-4">Company</h3>
<ul className="space-y-3">
{footerLinks.company.map((link) => (
<li key={link.label}>
<a href={link.href} className="text-dark-400 hover:text-brand-400 transition-colors">
{link.label}
</a>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-white uppercase tracking-wider mb-4">Contact</h3>
<address className="not-italic space-y-3 text-dark-400">
<div className="flex items-start gap-3">
<MapPin className="h-5 w-5 mt-0.5 text-brand-500 flex-shrink-0" />
<span>Belgium</span>
</div>
<div className="flex items-start gap-3">
<Mail className="h-5 w-5 mt-0.5 text-brand-500 flex-shrink-0" />
<a href="mailto:contact@urpi.be" className="hover:text-brand-400 transition-colors">contact@urpi.be</a>
</div>
<div className="flex items-start gap-3">
<Phone className="h-5 w-5 mt-0.5 text-brand-500 flex-shrink-0" />
<a href="tel:+32470000000" className="hover:text-brand-400 transition-colors">+32 470 00 00 00</a>
</div>
</address>
</div>
</div>
<div className="mt-12 pt-8 border-t border-dark-800 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-dark-500 text-sm">
© {new Date().getFullYear()} URPI. All rights reserved.
</p>
<div className="flex items-center gap-6">
{footerLinks.legal.map((link) => (
<Link key={link.label} to={link.href} className="text-sm text-dark-500 hover:text-brand-400 transition-colors">
{link.label}
</Link>
))}
</div>
</div>
</div>
</footer>
)
}

View file

@ -0,0 +1,109 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Menu, X, Code } from 'lucide-react'
import { Button } from '../ui/Button'
import { cn } from '../../utils/helpers'
const navItems = [
{ href: '#services', label: 'Services' },
{ href: '#projects', label: 'Projects' },
{ href: '#skills', label: 'Skills' },
{ href: '#experience', label: 'Experience' },
{ href: '#contact', label: 'Contact' },
]
export function Header() {
const [scrolled, setScrolled] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 20)
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const scrollToSection = (href: string) => {
const sectionId = href.replace('#', '')
const element = document.getElementById(sectionId)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
setMobileMenuOpen(false)
}
return (
<header
className={cn(
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
scrolled ? 'bg-white/95 backdrop-blur-lg shadow-sm border-b border-dark-100' : 'bg-transparent'
)}
>
<nav className="container" aria-label="Main navigation">
<div className="flex items-center justify-between h-16 lg:h-20">
<Link to="/" className="flex items-center gap-2 text-xl font-display font-bold text-dark-900" aria-label="URPI Home">
<Code className="h-8 w-8 text-brand-500" />
<span>URPI</span>
</Link>
<div className="hidden lg:flex items-center gap-8">
{navItems.map((item) => (
<a
key={item.href}
href={item.href}
onClick={(e) => {
e.preventDefault()
scrollToSection(item.href)
}}
className="text-sm font-medium text-dark-600 hover:text-brand-500 transition-colors"
>
{item.label}
</a>
))}
<Button variant="primary" size="sm" onClick={() => scrollToSection('#contact')}>
Get in Touch
</Button>
</div>
<div className="flex items-center gap-4 lg:hidden">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="p-2 rounded-lg text-dark-600 hover:bg-dark-100 transition-colors"
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
>
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</div>
</div>
<div
id="mobile-menu"
className={cn(
'lg:hidden overflow-hidden transition-all duration-300 ease-in-out border-t border-dark-100 bg-white',
mobileMenuOpen ? 'max-h-96 opacity-100 py-4' : 'max-h-0 opacity-0 py-0'
)}
>
<div className="flex flex-col gap-4 px-4">
{navItems.map((item) => (
<a
key={item.href}
href={item.href}
onClick={(e) => {
e.preventDefault()
scrollToSection(item.href)
}}
className="text-base font-medium text-dark-600 hover:text-brand-500 transition-colors py-2"
>
{item.label}
</a>
))}
<Button variant="primary" className="w-full" onClick={() => scrollToSection('#contact')}>
Get in Touch
</Button>
</div>
</div>
</nav>
</header>
)
}

View file

@ -0,0 +1,217 @@
import { useState } from 'react'
import { useApiMutation } from '../../hooks/useApi'
import { api } from '../../services/api'
import { type ContactForm } from '../../types'
import { Button } from '../ui/Button'
import { Input, Textarea } from '../ui/Input'
import { Card } from '../ui/Card'
import { Badge } from '../ui/Badge'
import { Mail, MapPin, Phone, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
import { validateEmail } from '../../utils/helpers'
export function Contact() {
const [formData, setFormData] = useState<ContactForm>({
name: '',
email: '',
subject: '',
message: '',
})
const [errors, setErrors] = useState<Partial<ContactForm>>({})
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('')
const { mutate: submitContact, loading } = useApiMutation(api.contact.submit)
const validateForm = (): boolean => {
const newErrors: Partial<ContactForm> = {}
if (!formData.name.trim() || formData.name.length < 2) {
newErrors.name = 'Name must be at least 2 characters'
}
if (!validateEmail(formData.email)) {
newErrors.email = 'Please enter a valid email address'
}
if (!formData.subject.trim() || formData.subject.length < 5) {
newErrors.subject = 'Subject must be at least 5 characters'
}
if (!formData.message.trim() || formData.message.length < 10) {
newErrors.message = 'Message must be at least 10 characters'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) return
setSubmitStatus('idle')
setErrorMessage('')
const result = await submitContact(formData)
if (result) {
setSubmitStatus('success')
setFormData({ name: '', email: '', subject: '', message: '' })
} else {
setSubmitStatus('error')
setErrorMessage('Failed to submit form. Please try again or email us directly.')
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
if (errors[name as keyof ContactForm]) {
setErrors(prev => ({ ...prev, [name]: undefined }))
}
}
const contactInfo = [
{ icon: Mail, label: 'Email', value: 'contact@urpi.be', href: 'mailto:contact@urpi.be' },
{ icon: MapPin, label: 'Location', value: 'Belgium', href: null },
{ icon: Phone, label: 'Phone', value: '+32 470 00 00 00', href: 'tel:+32470000000' },
]
return (
<section id="contact" className="section bg-white" aria-labelledby="contact-heading">
<div className="container">
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16">
<div>
<Badge className="mb-4">Get In Touch</Badge>
<h2 id="contact-heading" className="section-title">Let's Build Something Great</h2>
<p className="section-subtitle mt-4">
Have a project in mind? Need technical guidance? Looking for a development partner?
Let's start a conversation.
</p>
<div className="mt-10 space-y-6">
{contactInfo.map((item) => (
<div key={item.label} className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-brand-100 text-brand-500 flex items-center justify-center">
<item.icon className="h-6 w-6" />
</div>
<div>
<p className="text-sm font-medium text-dark-900">{item.label}</p>
{item.href ? (
<a href={item.href} className="text-dark-600 hover:text-brand-500 transition-colors">
{item.value}
</a>
) : (
<p className="text-dark-600">{item.value}</p>
)}
</div>
</div>
))}
</div>
<div className="mt-10 p-6 bg-dark-50 rounded-2xl">
<h3 className="text-lg font-semibold text-dark-900 mb-2">Typical Response Time</h3>
<p className="text-dark-600">We usually respond within 24 hours during business days.</p>
</div>
</div>
<div>
<Card variant="glass" className="p-8">
{submitStatus === 'success' ? (
<div className="text-center py-12">
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="h-8 w-8 text-green-500" />
</div>
<h3 className="text-2xl font-bold text-dark-900 mb-2">Message Sent!</h3>
<p className="text-dark-600 mb-6">Thanks for reaching out. We'll get back to you within 24 hours.</p>
<Button variant="outline" onClick={() => setSubmitStatus('idle')}>
Send Another Message
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
{submitStatus === 'error' && (
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg" role="alert">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0" />
<p className="text-red-700">{errorMessage}</p>
</div>
)}
<Input
label="Name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Your name"
error={errors.name}
required
autoComplete="name"
/>
<Input
label="Email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="your@email.com"
error={errors.email}
required
autoComplete="email"
/>
<Input
label="Subject"
name="subject"
value={formData.subject}
onChange={handleChange}
placeholder="What's this about?"
error={errors.subject}
required
/>
<Textarea
label="Message"
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Tell us about your project, timeline, budget, or just say hello..."
error={errors.message}
required
rows={5}
/>
<Button
type="submit"
className="w-full"
size="lg"
loading={loading}
>
{loading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Sending...
</>
) : (
<>
Send Message
<Send className="h-5 w-5" />
</>
)}
</Button>
<p className="text-xs text-dark-500 text-center">
By submitting this form, you agree to our{' '}
<a href="/privacy" className="underline hover:text-brand-500">Privacy Policy</a>
. We never share your information with third parties.
</p>
</form>
)}
</Card>
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,120 @@
import { useApi } from '../../hooks/useApi'
import { api } from '../../services/api'
import { useCallback } from 'react'
import { type Experience } from '../../types'
import { Card, CardContent } from '../ui/Card'
import { Badge } from '../ui/Badge'
import { formatDateRange } from '../../utils/helpers'
import { Building2, MapPin, Calendar, Code2 } from 'lucide-react'
export function Experience() {
const fetchExperience = useCallback(() => api.experience.getAll(), [])
const { data: experiences, loading, error } = useApi(fetchExperience)
if (loading) {
return (
<section id="experience" className="section bg-dark-50" aria-labelledby="experience-heading">
<div className="container">
<div className="space-y-8">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="space-y-4">
<div className="h-6 w-1/3 bg-dark-200 rounded" />
<div className="h-5 w-1/4 bg-dark-200 rounded" />
<div className="h-4 w-full bg-dark-200 rounded" />
<div className="h-4 w-5/6 bg-dark-200 rounded" />
<div className="flex gap-2">
{[1, 2, 3, 4].map((j) => (
<div key={j} className="h-6 w-20 bg-dark-200 rounded" />
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
)
}
if (error) {
return (
<section id="experience" className="section bg-dark-50" aria-labelledby="experience-heading">
<div className="container text-center py-12">
<p className="text-brand-500">Failed to load experience. Please try again later.</p>
</div>
</section>
)
}
return (
<section id="experience" className="section bg-dark-50" aria-labelledby="experience-heading">
<div className="container">
<div className="text-center max-w-3xl mx-auto mb-16">
<Badge className="mb-4">Professional Journey</Badge>
<h2 id="experience-heading" className="section-title">Experience</h2>
<p className="section-subtitle mx-auto">
Over a decade of building software solutions for startups, scale-ups, and enterprises.
From hands-on development to technical leadership and architecture.
</p>
</div>
<div className="relative max-w-3xl mx-auto">
<div className="absolute left-8 top-0 bottom-0 w-0.5 bg-gradient-to-b from-brand-500 to-transparent" />
<div className="space-y-12">
{experiences?.map((exp) => (
<div key={exp.id} className="relative">
<div className="absolute left-0 top-4 w-4 h-4 rounded-full bg-brand-500 border-4 bg-white ring-2 ring-brand-500 z-10" />
<Card variant="hover" className="pl-6 ml-6">
<CardContent className="space-y-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-brand-100 text-brand-500 flex items-center justify-center">
<Building2 className="h-6 w-6" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h3 className="text-xl font-bold text-dark-900">{exp.role}</h3>
<span className="px-2 py-0.5 bg-brand-100 text-brand-700 text-xs font-medium rounded">
{exp.current ? 'Current' : 'Past'}
</span>
</div>
<p className="text-brand-500 font-medium">{exp.company}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-dark-500 pl-16">
<span className="flex items-center gap-1.5">
<MapPin className="h-4 w-4" />
{exp.location}
</span>
<span className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
{formatDateRange(exp.start_date, exp.end_date, exp.current)}
</span>
</div>
<p className="text-dark-600 pl-16">{exp.description}</p>
<div className="flex flex-wrap gap-2 pl-16">
{exp.tech_stack.slice(0, 8).map((tech) => (
<Badge key={tech} variant="outline" size="sm">
<Code2 className="h-3 w-3" />
{tech}
</Badge>
))}
{exp.tech_stack.length > 8 && (
<Badge variant="outline" size="sm">+{exp.tech_stack.length - 8} more</Badge>
)}
</div>
</CardContent>
</Card>
</div>
))}
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,83 @@
import { ArrowRight, Code, Zap, Globe } from 'lucide-react'
import { Button } from '../ui/Button'
export function Hero() {
const scrollToContact = () => {
const element = document.getElementById('contact')
if (element) element.scrollIntoView({ behavior: 'smooth' })
}
const stats = [
{ value: '50+', label: 'Projects Delivered' },
{ value: '10+', label: 'Years Experience' },
{ value: '15+', label: 'Technologies' },
{ value: '100%', label: 'Client Satisfaction' },
]
return (
<section id="home" className="relative min-h-screen flex items-center justify-center overflow-hidden pt-16 lg:pt-20">
<div className="absolute inset-0 bg-gradient-to-br from-brand-50 via-white to-dark-50" />
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
<div className="absolute top-20 right-10 w-96 h-96 bg-brand-500/10 rounded-full blur-3xl animate-float" />
<div className="absolute bottom-20 left-10 w-72 h-72 bg-brand-500/10 rounded-full blur-3xl animate-float" style={{ animationDelay: '2s' }} />
<div className="container relative py-20 lg:py-32">
<div className="max-w-4xl mx-auto text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-brand-100 text-brand-700 text-sm font-medium mb-8 animate-fade-in">
<Zap className="h-4 w-4" />
<span>Now accepting new projects for Q1 2026</span>
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-display font-bold text-dark-900 tracking-tight mb-6 animate-slide-up">
Building <span className="gradient-text">Digital Products</span> That Scale
</h1>
<p className="text-lg sm:text-xl lg:text-2xl text-dark-600 mb-10 max-w-3xl mx-auto animate-slide-up animate-delay-100">
Expert web application & backend development. Go, Java, React, Cloud-native architectures.
From concept to production — we deliver maintainable, scalable software.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16 animate-slide-up animate-delay-200">
<Button size="lg" onClick={scrollToContact} className="gap-2">
Start a Project
<ArrowRight className="h-5 w-5" />
</Button>
<Button variant="outline" size="lg" onClick={() => document.getElementById('projects')?.scrollIntoView({ behavior: 'smooth' })}>
View Work
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-16 animate-slide-up animate-delay-300">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<div className="text-3xl sm:text-4xl font-display font-bold text-brand-500 mb-1">{stat.value}</div>
<div className="text-sm text-dark-600">{stat.label}</div>
</div>
))}
</div>
<div className="flex flex-wrap items-center justify-center gap-6 text-sm text-dark-500 animate-slide-up animate-delay-400">
<span className="flex items-center gap-1.5">
<Code className="h-4 w-4" />
Go • Java • React • TypeScript
</span>
<span className="flex items-center gap-1.5">
<Globe className="h-4 w-4" />
Kubernetes • AWS • GCP
</span>
<span className="flex items-center gap-1.5">
<Zap className="h-4 w-4" />
Microservices • APIs • DevOps
</span>
</div>
</div>
</div>
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 animate-bounce-subtle">
<svg className="h-8 w-8 text-dark-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
</section>
)
}

View file

@ -0,0 +1,157 @@
import { useApi } from '../../hooks/useApi'
import { api } from '../../services/api'
import { useCallback } from 'react'
import { Card, CardContent } from '../ui/Card'
import { Badge } from '../ui/Badge'
import { Button } from '../ui/Button'
import { Github, ExternalLink, Code2, Database, Cloud } from 'lucide-react'
const techIcons: Record<string, React.ComponentType<{ className?: string }>> = {
go: Code2,
java: Code2,
react: Code2,
kubernetes: Cloud,
docker: Cloud,
postgresql: Database,
redis: Database,
aws: Cloud,
gcp: Cloud,
grpc: Code2,
typescript: Code2,
}
export function Projects() {
const fetchFeaturedProjects = useCallback(() => api.projects.getFeatured(), [])
const { data: projects, loading, error } = useApi(fetchFeaturedProjects)
if (loading) {
return (
<section id="projects" className="section bg-dark-50" aria-labelledby="projects-heading">
<div className="container">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse overflow-hidden">
<div className="aspect-video bg-dark-200" />
<CardContent className="space-y-4">
<div className="h-6 w-3/4 bg-dark-200 rounded" />
<div className="h-4 w-full bg-dark-200 rounded" />
<div className="h-4 w-5/6 bg-dark-200 rounded" />
<div className="flex gap-2">
{[1, 2, 3].map((j) => (
<div key={j} className="h-6 w-16 bg-dark-200 rounded" />
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
)
}
if (error) {
return (
<section id="projects" className="section bg-dark-50" aria-labelledby="projects-heading">
<div className="container text-center py-12">
<p className="text-brand-500">Failed to load projects. Please try again later.</p>
</div>
</section>
)
}
const getTechIcon = (tech: string) => {
const key = tech.toLowerCase()
return techIcons[key] || Code2
}
return (
<section id="projects" className="section bg-dark-50" aria-labelledby="projects-heading">
<div className="container">
<div className="text-center max-w-3xl mx-auto mb-16">
<Badge className="mb-4">Featured Work</Badge>
<h2 id="projects-heading" className="section-title">Selected Projects</h2>
<p className="section-subtitle mx-auto">
A showcase of projects demonstrating expertise across full-stack development,
backend systems, cloud infrastructure, and real-time applications.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects?.map((project) => (
<Card key={project.id} variant="hover" className="overflow-hidden group">
{project.image_url && (
<div className="aspect-video relative overflow-hidden">
<img
src={project.image_url}
alt={project.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-dark-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
)}
<CardContent className="space-y-4">
<div className="flex items-center gap-2">
<Badge variant="dark">{project.category}</Badge>
{project.featured && (
<Badge variant="brand">Featured</Badge>
)}
</div>
<h3 className="text-xl font-bold text-dark-900 group-hover:text-brand-500 transition-colors">
{project.title}
</h3>
<p className="text-dark-600 line-clamp-3">{project.short_desc || project.description}</p>
<div className="flex flex-wrap gap-2">
{project.tech_stack.slice(0, 6).map((tech) => {
const Icon = getTechIcon(tech)
return (
<Badge key={tech} variant="outline" className="gap-1">
<Icon className="h-3 w-3" />
{tech}
</Badge>
)
})}
{project.tech_stack.length > 6 && (
<Badge variant="outline">+{project.tech_stack.length - 6} more</Badge>
)}
</div>
<div className="flex items-center gap-4 pt-2 border-t border-dark-100">
{project.demo_url && (
<a
href={project.demo_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-brand-500 hover:text-brand-600 flex items-center gap-1"
>
<ExternalLink className="h-4 w-4" />
Demo
</a>
)}
{project.repo_url && (
<a
href={project.repo_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-dark-500 hover:text-brand-500 flex items-center gap-1"
>
<Github className="h-4 w-4" />
Code
</a>
)}
</div>
</CardContent>
</Card>
))}
</div>
<div className="text-center mt-12">
<Button variant="outline" size="lg" onClick={() => document.getElementById('projects')?.scrollIntoView({ behavior: 'smooth' })}>
View All Projects
<ExternalLink className="h-5 w-5" />
</Button>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,112 @@
import { useApi } from '../../hooks/useApi'
import { api } from '../../services/api'
import { useCallback } from 'react'
import { Card, CardContent } from '../ui/Card'
import { Badge } from '../ui/Badge'
import { Button } from '../ui/Button'
import {
Code,
Server,
Cloud,
Layout,
RefreshCw,
UserCheck,
ArrowRight,
Check,
} from 'lucide-react'
const serviceIcons: Record<string, React.ComponentType<{ className?: string }>> = {
code: Code,
server: Server,
cloud: Cloud,
architecture: Layout,
refresh: RefreshCw,
advisor: UserCheck,
}
export function Services() {
const fetchServices = useCallback(() => api.services.getAll(), [])
const { data: services, loading, error } = useApi(fetchServices)
if (loading) {
return (
<section id="services" className="section bg-white" aria-labelledby="services-heading">
<div className="container">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="space-y-4">
<div className="h-12 w-12 rounded-xl bg-dark-200" />
<div className="h-6 w-3/4 bg-dark-200 rounded" />
<div className="h-4 w-full bg-dark-200 rounded" />
<div className="h-4 w-5/6 bg-dark-200 rounded" />
<div className="space-y-2">
{[1, 2, 3].map((j) => (
<div key={j} className="h-4 w-full bg-dark-200 rounded" />
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
)
}
if (error) {
return (
<section id="services" className="section bg-white" aria-labelledby="services-heading">
<div className="container text-center py-12">
<p className="text-brand-500">Failed to load services. Please try again later.</p>
</div>
</section>
)
}
return (
<section id="services" className="section bg-white" aria-labelledby="services-heading">
<div className="container">
<div className="text-center max-w-3xl mx-auto mb-16">
<Badge className="mb-4">What We Do</Badge>
<h2 id="services-heading" className="section-title">Services & Expertise</h2>
<p className="section-subtitle mx-auto">
Comprehensive software development services — from initial architecture to ongoing maintenance.
We specialize in building robust, scalable systems that drive business value.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{services?.map((service) => {
const Icon = serviceIcons[service.icon || 'code'] || Code
return (
<Card key={service.id} variant="hover" className="group">
<CardContent className="space-y-4 h-full flex flex-col">
<div className="w-12 h-12 rounded-xl bg-brand-100 text-brand-500 flex items-center justify-center group-hover:bg-brand-500 group-hover:text-white transition-all duration-300">
<Icon className="h-7 w-7" />
</div>
<div className="flex-1">
<h3 className="text-xl font-bold text-dark-900 mb-2">{service.title}</h3>
<p className="text-dark-600 mb-4">{service.description}</p>
<ul className="space-y-2">
{service.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm text-dark-600">
<Check className="h-4 w-4 text-brand-500 flex-shrink-0 mt-0.5" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
<Button variant="ghost" className="w-full justify-start group-hover:text-brand-500">
Learn more
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</Button>
</CardContent>
</Card>
)
})}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,112 @@
import { useApi } from '../../hooks/useApi'
import { api } from '../../services/api'
import { useCallback } from 'react'
import { type Skill } from '../../types'
import { Card, CardContent } from '../ui/Card'
import { Badge } from '../ui/Badge'
import { cn } from '../../utils/helpers'
import { getProficiencyLabel, getProficiencyColor } from '../../utils/helpers'
const categories = ['Backend', 'Frontend', 'Architecture', 'DevOps', 'Database', 'API']
export function Skills() {
const fetchSkills = useCallback(() => api.skills.getAll(), [])
const { data: skills, loading, error } = useApi(fetchSkills)
if (loading) {
return (
<section id="skills" className="section bg-white" aria-labelledby="skills-heading">
<div className="container">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[...Array(12)].map((_, i) => (
<Card key={i} className="animate-pulse">
<CardContent className="space-y-3">
<div className="h-6 w-3/4 bg-dark-200 rounded" />
<div className="h-2 w-full bg-dark-200 rounded" />
<div className="h-2 w-5/6 bg-dark-200 rounded" />
</CardContent>
</Card>
))}
</div>
</div>
</section>
)
}
if (error) {
return (
<section id="skills" className="section bg-white" aria-labelledby="skills-heading">
<div className="container text-center py-12">
<p className="text-brand-500">Failed to load skills. Please try again later.</p>
</div>
</section>
)
}
const skillsByCategory = skills?.reduce((acc, skill) => {
if (!acc[skill.category]) acc[skill.category] = []
acc[skill.category].push(skill)
return acc
}, {} as Record<string, Skill[]>) || {}
const sortedCategories = categories.filter(c => skillsByCategory[c]?.length)
return (
<section id="skills" className="section bg-white" aria-labelledby="skills-heading">
<div className="container">
<div className="text-center max-w-3xl mx-auto mb-16">
<Badge className="mb-4">Technologies & Expertise</Badge>
<h2 id="skills-heading" className="section-title">Technical Skills</h2>
<p className="section-subtitle mx-auto">
Deep expertise across the full stack — from backend systems and cloud infrastructure
to modern frontend frameworks and API design.
</p>
</div>
<div className="space-y-16">
{sortedCategories.map((category) => {
const categorySkills = skillsByCategory[category] || []
return (
<div key={category}>
<h3 className="text-2xl font-bold text-dark-900 mb-6 flex items-center gap-3">
<span className="w-1 h-8 bg-brand-500 rounded-full" />
{category}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{categorySkills
.sort((a, b) => b.proficiency - a.proficiency)
.map((skill) => (
<Card key={skill.id} variant="hover" className="group">
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{skill.icon && (
<div className="w-10 h-10 rounded-lg bg-brand-100 text-brand-500 flex items-center justify-center text-xl font-mono font-bold">
{skill.icon.slice(0, 3).toUpperCase()}
</div>
)}
<span className="font-medium text-dark-900">{skill.name}</span>
</div>
<Badge variant="brand" size="sm">{getProficiencyLabel(skill.proficiency)}</Badge>
</div>
<div className="h-2 bg-dark-100 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-1000 ease-out',
getProficiencyColor(skill.proficiency)
)}
style={{ width: `${(skill.proficiency / 5) * 100}%` }}
/>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
})}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,37 @@
import { type HTMLAttributes } from 'react'
import { cn } from '../../utils/helpers'
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: 'brand' | 'dark' | 'success' | 'warning' | 'error' | 'outline'
size?: 'sm' | 'md'
}
export const Badge = ({ className, variant = 'brand', size = 'md', children, ...props }: BadgeProps) => {
const variants = {
brand: 'bg-brand-100 text-brand-700',
dark: 'bg-dark-100 text-dark-700',
success: 'bg-green-100 text-green-700',
warning: 'bg-yellow-100 text-yellow-700',
error: 'bg-red-100 text-red-700',
outline: 'border border-dark-200 text-dark-600 hover:bg-dark-100',
}
const sizes = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-3 py-1 text-sm',
}
return (
<span
className={cn(
'inline-flex items-center font-medium rounded-full',
variants[variant],
sizes[size],
className
)}
{...props}
>
{children}
</span>
)
}

View file

@ -0,0 +1,46 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { cn } from '../../utils/helpers'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', loading, children, disabled, ...props }, ref) => {
const baseStyles = 'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
const variants = {
primary: 'bg-brand-500 text-white hover:bg-brand-600 focus-visible:ring-brand-500 shadow-lg shadow-brand-500/25',
secondary: 'bg-dark-100 text-dark-900 hover:bg-dark-200 focus-visible:ring-dark-500 border border-dark-200',
outline: 'border-2 border-brand-500 text-brand-500 hover:bg-brand-500 hover:text-white focus-visible:ring-brand-500',
ghost: 'text-dark-600 hover:text-dark-900 hover:bg-dark-100 focus-visible:ring-dark-500',
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-5 py-2.5 text-base',
lg: 'px-7 py-3.5 text-lg',
}
return (
<button
ref={ref}
className={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
{children}
</button>
)
}
)
Button.displayName = 'Button'

View file

@ -0,0 +1,66 @@
import { forwardRef, type HTMLAttributes } from 'react'
import { cn } from '../../utils/helpers'
interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'hover' | 'glass'
padding?: 'none' | 'sm' | 'md' | 'lg'
}
export const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, variant = 'default', padding = 'md', children, ...props }, ref) => {
const variants = {
default: 'bg-white rounded-2xl border border-dark-100 overflow-hidden transition-all duration-300',
hover: 'bg-white rounded-2xl border border-dark-100 overflow-hidden transition-all duration-300 hover:shadow-xl hover:border-brand-200 hover:-translate-y-1 hover:shadow-glow',
glass: 'bg-white/80 backdrop-blur-lg border border-white/20 rounded-2xl overflow-hidden',
}
const paddings = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
}
return (
<div
ref={ref}
className={cn(variants[variant], paddings[padding], className)}
{...props}
>
{children}
</div>
)
}
)
Card.displayName = 'Card'
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => (
<div ref={ref} className={cn('mb-4', className)} {...props}>
{children}
</div>
)
)
CardHeader.displayName = 'CardHeader'
export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, children, ...props }, ref) => (
<h3 ref={ref} className={cn('text-xl font-bold text-dark-900', className)} {...props}>
{children}
</h3>
)
)
CardTitle.displayName = 'CardTitle'
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => (
<div ref={ref} className={cn('', className)} {...props}>
{children}
</div>
)
)
CardContent.displayName = 'CardContent'

View file

@ -0,0 +1,98 @@
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
import { cn } from '../../utils/helpers'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, helperText, id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-dark-700 mb-1.5">
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={cn(
'w-full px-4 py-3 rounded-lg border bg-white text-dark-900 placeholder-dark-400 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-500/20',
error
? 'border-brand-500 focus:border-brand-500'
: 'border-dark-200 focus:border-brand-500',
className
)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined}
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-brand-500" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-dark-500">
{helperText}
</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
helperText?: string
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, error, helperText, id, ...props }, ref) => {
const textareaId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label htmlFor={textareaId} className="block text-sm font-medium text-dark-700 mb-1.5">
{label}
</label>
)}
<textarea
ref={ref}
id={textareaId}
className={cn(
'w-full px-4 py-3 rounded-lg border bg-white text-dark-900 placeholder-dark-400 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-500/20 resize-y min-h-[120px]',
error
? 'border-brand-500 focus:border-brand-500'
: 'border-dark-200 focus:border-brand-500',
className
)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? `${textareaId}-error` : helperText ? `${textareaId}-helper` : undefined}
{...props}
/>
{error && (
<p id={`${textareaId}-error`} className="mt-1.5 text-sm text-brand-500" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p id={`${textareaId}-helper`} className="mt-1.5 text-sm text-dark-500">
{helperText}
</p>
)}
</div>
)
}
)
Textarea.displayName = 'Textarea'