initial commit
This commit is contained in:
commit
e7987e88a7
70 changed files with 13661 additions and 0 deletions
122
web/src/components/layout/Footer.tsx
Normal file
122
web/src/components/layout/Footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
web/src/components/layout/Header.tsx
Normal file
109
web/src/components/layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
217
web/src/components/sections/Contact.tsx
Normal file
217
web/src/components/sections/Contact.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
web/src/components/sections/Experience.tsx
Normal file
120
web/src/components/sections/Experience.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
web/src/components/sections/Hero.tsx
Normal file
83
web/src/components/sections/Hero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
157
web/src/components/sections/Projects.tsx
Normal file
157
web/src/components/sections/Projects.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
web/src/components/sections/Services.tsx
Normal file
112
web/src/components/sections/Services.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
web/src/components/sections/Skills.tsx
Normal file
112
web/src/components/sections/Skills.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
web/src/components/ui/Badge.tsx
Normal file
37
web/src/components/ui/Badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
web/src/components/ui/Button.tsx
Normal file
46
web/src/components/ui/Button.tsx
Normal 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'
|
||||
66
web/src/components/ui/Card.tsx
Normal file
66
web/src/components/ui/Card.tsx
Normal 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'
|
||||
98
web/src/components/ui/Input.tsx
Normal file
98
web/src/components/ui/Input.tsx
Normal 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'
|
||||
Loading…
Add table
Add a link
Reference in a new issue