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

18
web/index.html Normal file
View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="URPI - Web Application & Backend Development. Expert in Go, Java, React, Cloud Infrastructure & System Architecture." />
<meta name="theme-color" content="#ed7315" />
<title>URPI | Web Application & Backend Development</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6018
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

52
web/package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "go-first-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"",
"test": "vitest run",
"test:ui": "vitest --ui",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm run test && npm run test:e2e"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^0.436.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.1",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"happy-dom": "^20.14.5",
"jsdom": "^30.1.2",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.6",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4",
"vite": "^5.4.3",
"vitest": "^1.6.0"
}
}

6
web/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

4
web/public/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#ed7315"/>
<path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

8
web/public/grid.svg Normal file
View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.3"/>
</pattern>
</defs>
<rect width="100" height="100" fill="url(#grid)"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

27
web/src/App.tsx Normal file
View file

@ -0,0 +1,27 @@
import { Header } from './components/layout/Header'
import { Footer } from './components/layout/Footer'
import { Hero } from './components/sections/Hero'
import { Services } from './components/sections/Services'
import { Projects } from './components/sections/Projects'
import { Skills } from './components/sections/Skills'
import { Experience } from './components/sections/Experience'
import { Contact } from './components/sections/Contact'
function App() {
return (
<div className="min-h-screen bg-white">
<Header />
<main id="main-content" className="pt-16 lg:pt-20">
<Hero />
<Services />
<Projects />
<Skills />
<Experience />
<Contact />
</main>
<Footer />
</div>
)
}
export default App

View file

@ -0,0 +1,129 @@
import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BrowserRouter } from 'react-router-dom'
import { describe, it, expect, beforeEach } from 'vitest'
import App from '../App'
import { mockFetchResponse, resetFetchMock, getFetchCalls } from './api-mock'
const renderApp = () => {
return render(
<BrowserRouter>
<App />
</BrowserRouter>
)
}
const mockSkills = [
{ id: '1', name: 'Go', category: 'Backend', proficiency: 5, icon: 'go', order: 1, created_at: '', updated_at: '' },
{ id: '2', name: 'React', category: 'Frontend', proficiency: 4, icon: 'react', order: 2, created_at: '', updated_at: '' },
]
const mockProjects = [
{ id: '1', title: 'Test Project', description: 'Desc', short_desc: 'Short', tech_stack: ['Go', 'React'], image_url: '', demo_url: '', repo_url: '', category: 'Full Stack', featured: true, order: 1, created_at: '', updated_at: '' },
]
const mockExperience = [
{ id: '1', company: 'Test Co', role: 'Dev', location: 'Remote', start_date: '2020-01-01', end_date: null, current: true, description: 'Test', tech_stack: ['Go'], order: 1, created_at: '', updated_at: '' },
]
const mockServices = [
{ id: '1', title: 'Web Dev', description: 'Desc', icon: 'code', features: ['React', 'Go'], order: 1, active: true, created_at: '', updated_at: '' },
]
describe('App Integration Tests', () => {
beforeEach(() => {
resetFetchMock()
})
it('renders all sections without errors', async () => {
mockFetchResponse(mockSkills)
mockFetchResponse(mockProjects)
mockFetchResponse(mockProjects)
mockFetchResponse(mockExperience)
mockFetchResponse(mockServices)
renderApp()
await waitFor(() => {
expect(screen.getByText('Services & Expertise')).toBeInTheDocument()
expect(screen.getByText('Selected Projects')).toBeInTheDocument()
expect(screen.getByText('Technical Skills')).toBeInTheDocument()
expect(screen.getByText('Experience')).toBeInTheDocument()
expect(screen.getByText("Let's Build Something Great")).toBeInTheDocument()
}, { timeout: 15000 })
})
it('does not make excessive API calls on initial render', async () => {
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
renderApp()
await waitFor(() => {
expect(screen.getByText('Services & Expertise')).toBeInTheDocument()
}, { timeout: 15000 })
const calls = getFetchCalls()
expect(calls.length).toBeLessThanOrEqual(6)
})
it('handles API errors gracefully', async () => {
;(globalThis.fetch as unknown as { mockResolvedValueOnce: (value: unknown) => void }).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ success: false, error: { code: 'INTERNAL_ERROR', message: 'Server error' } }),
})
;(globalThis.fetch as unknown as { mockResolvedValueOnce: (value: unknown) => void }).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, data: [] }),
})
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
renderApp()
await waitFor(() => {
expect(screen.getByText('Services & Expertise')).toBeInTheDocument()
}, { timeout: 15000 })
expect(screen.getByText('Selected Projects')).toBeInTheDocument()
})
it('contact form submission works', async () => {
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse({ id: '1', name: 'Test', email: 'test@test.com', subject: 'Test', message: 'Test message', status: 'new', created_at: '', updated_at: '' })
renderApp()
await waitFor(() => {
expect(screen.getByText("Let's Build Something Great")).toBeInTheDocument()
}, { timeout: 15000 })
// Use getByPlaceholderText for more specific targeting
const nameInput = screen.getByPlaceholderText('Your name')
const emailInput = screen.getByPlaceholderText('your@email.com')
const subjectInput = screen.getByPlaceholderText("What's this about?")
const messageInput = screen.getByPlaceholderText("Tell us about your project, timeline, budget, or just say hello...")
const submitButton = screen.getByRole('button', { name: /send message/i })
await userEvent.type(nameInput, 'John Doe')
await userEvent.type(emailInput, 'john@example.com')
await userEvent.type(subjectInput, 'Project Inquiry')
await userEvent.type(messageInput, 'Hello, I would like to discuss a project.')
await act(async () => {
await userEvent.click(submitButton)
})
await waitFor(() => {
expect(screen.getByText('Message Sent!')).toBeInTheDocument()
}, { timeout: 15000 })
})
})

View file

@ -0,0 +1,86 @@
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, expect, beforeEach } from 'vitest'
import { Services } from '../components/sections/Services'
import { mockFetchResponse, resetFetchMock, getFetchCalls } from './api-mock'
const renderServices = () => {
return render(<Services />)
}
const mockServices = [
{ id: '1', title: 'Web Dev', description: 'Desc', icon: 'code', features: ['React', 'Go'], order: 1, active: true, created_at: '', updated_at: '' },
{ id: '2', title: 'Backend', description: 'Desc', icon: 'server', features: ['Go', 'Java'], order: 2, active: true, created_at: '', updated_at: '' },
]
describe('Services Section - Infinite Loop Detection', () => {
beforeEach(() => {
resetFetchMock()
})
it('fetches services only once on mount', async () => {
mockFetchResponse(mockServices)
renderServices()
await waitFor(() => {
expect(screen.getByText('Web Dev')).toBeInTheDocument()
}, { timeout: 10000 })
const initialCalls = getFetchCalls().length
await new Promise(resolve => setTimeout(resolve, 100))
const finalCalls = getFetchCalls().length
expect(finalCalls).toBe(initialCalls)
})
it('does not re-fetch when parent re-renders', async () => {
mockFetchResponse(mockServices)
const { rerender } = renderServices()
await waitFor(() => {
expect(screen.getByText('Web Dev')).toBeInTheDocument()
}, { timeout: 10000 })
const initialCalls = getFetchCalls().length
rerender(<Services />)
await new Promise(resolve => setTimeout(resolve, 100))
const finalCalls = getFetchCalls().length
expect(finalCalls).toBe(initialCalls)
})
it('shows loading skeleton initially', async () => {
;(globalThis.fetch as unknown as { mockImplementation: (fn: () => Promise<unknown>) => void }).mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({
ok: true,
json: () => Promise.resolve({ success: true, data: mockServices }),
}), 100))
)
renderServices()
// Should show loading state briefly
await waitFor(() => {
expect(screen.getByText('Web Dev')).toBeInTheDocument()
}, { timeout: 5000 })
})
it('shows error state on API failure', async () => {
;(globalThis.fetch as unknown as { mockResolvedValueOnce: (value: unknown) => void }).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ success: false, error: { code: 'INTERNAL_ERROR', message: 'Failed to load' } }),
})
renderServices()
await waitFor(() => {
expect(screen.getByText(/failed to load services/i)).toBeInTheDocument()
}, { timeout: 10000 })
})
})

View file

@ -0,0 +1,65 @@
export interface MockApiResponse<T> {
success: boolean
data?: T
error?: {
code: string
message: string
details?: string
}
meta?: {
page: number
page_size: number
total: number
total_pages: number
}
}
export function createMockResponse<T>(data: T, meta?: MockApiResponse<T>['meta']): MockApiResponse<T> {
return {
success: true,
data,
meta,
}
}
export function createMockError(code: string, message: string, details?: string): MockApiResponse<never> {
return {
success: false,
error: { code, message, details },
}
}
export function mockFetchResponse<T>(data: T, meta?: MockApiResponse<T>['meta']): void {
const response = createMockResponse(data, meta)
;(globalThis.fetch as unknown as { mockResolvedValueOnce: (value: unknown) => void }).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(response),
})
}
export function mockFetchError(code: string, message: string, details?: string): void {
const response = createMockError(code, message, details)
;(globalThis.fetch as unknown as { mockResolvedValueOnce: (value: unknown) => void }).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve(response),
})
}
export function mockFetchNetworkError(): void {
;(globalThis.fetch as unknown as { mockRejectedValueOnce: (value: unknown) => void }).mockRejectedValueOnce(new Error('Network error'))
}
export function getFetchCalls(): Array<{ url: string; options?: RequestInit }> {
const mockFetch = globalThis.fetch as unknown as { mock: { calls: Array<[string, RequestInit?]> } }
return mockFetch.mock.calls.map(([url, options]) => ({ url, options }))
}
export function getLastFetchCall(): { url: string; options?: RequestInit } | undefined {
const calls = getFetchCalls()
return calls[calls.length - 1]
}
export function resetFetchMock(): void {
const mockFetch = globalThis.fetch as unknown as { mockClear: () => void }
mockFetch.mockClear()
}

View file

@ -0,0 +1,130 @@
import { render, screen, waitFor } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { describe, it, expect, beforeEach } from 'vitest'
import App from '../App'
import { mockFetchResponse, resetFetchMock, getFetchCalls } from './api-mock'
const renderApp = () => {
return render(
<BrowserRouter>
<App />
</BrowserRouter>
)
}
const mockSkills = [
{ id: '1', name: 'Go', category: 'Backend', proficiency: 5, icon: 'go', order: 1, created_at: '', updated_at: '' },
]
const mockProjects = [
{ id: '1', title: 'Test Project', description: 'Desc', short_desc: 'Short', tech_stack: ['Go', 'React'], image_url: '', demo_url: '', repo_url: '', category: 'Full Stack', featured: true, order: 1, created_at: '', updated_at: '' },
]
const mockExperience = [
{ id: '1', company: 'Test Co', role: 'Dev', location: 'Remote', start_date: '2020-01-01', end_date: null, current: true, description: 'Test', tech_stack: ['Go'], order: 1, created_at: '', updated_at: '' },
]
const mockServices = [
{ id: '1', title: 'Web Dev', description: 'Desc', icon: 'code', features: ['React', 'Go'], order: 1, active: true, created_at: '', updated_at: '' },
]
describe('Performance & Infinite Loop Detection', () => {
beforeEach(() => {
resetFetchMock()
})
it('fetches data only once on mount', async () => {
mockFetchResponse(mockSkills)
mockFetchResponse(mockProjects)
mockFetchResponse(mockProjects)
mockFetchResponse(mockExperience)
mockFetchResponse(mockServices)
renderApp()
await waitFor(() => {
expect(screen.getByText('Services & Expertise')).toBeInTheDocument()
}, { timeout: 15000 })
const initialCalls = getFetchCalls().length
await new Promise(resolve => setTimeout(resolve, 100))
const finalCalls = getFetchCalls().length
expect(finalCalls).toBe(initialCalls)
})
it('does not re-fetch on component re-render without dependency changes', async () => {
mockFetchResponse(mockSkills)
mockFetchResponse(mockProjects)
mockFetchResponse(mockProjects)
mockFetchResponse(mockExperience)
mockFetchResponse(mockServices)
const { rerender } = renderApp()
await waitFor(() => {
expect(screen.getByText('Services & Expertise')).toBeInTheDocument()
}, { timeout: 15000 })
const initialCalls = getFetchCalls().length
rerender(
<BrowserRouter>
<App />
</BrowserRouter>
)
await new Promise(resolve => setTimeout(resolve, 100))
const finalCalls = getFetchCalls().length
expect(finalCalls).toBe(initialCalls)
})
it('does not make excessive fetch calls', async () => {
let callCount = 0
;(globalThis.fetch as unknown as { mockImplementation: (fn: () => Promise<unknown>) => void }).mockImplementation(() => {
callCount++
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true, data: [] }),
})
})
renderApp()
await waitFor(() => {
expect(screen.getByText('Services & Expertise')).toBeInTheDocument()
}, { timeout: 15000 })
const initialCallCount = callCount
await new Promise(resolve => setTimeout(resolve, 100))
expect(callCount).toBeLessThanOrEqual(initialCallCount + 1)
})
it('Skills section does not cause infinite re-fetches', async () => {
mockFetchResponse(mockSkills)
mockFetchResponse(mockProjects)
mockFetchResponse(mockProjects)
mockFetchResponse(mockExperience)
mockFetchResponse(mockServices)
renderApp()
await waitFor(() => {
expect(screen.getByText('Technical Skills')).toBeInTheDocument()
}, { timeout: 15000 })
const initialCalls = getFetchCalls().length
await new Promise(resolve => setTimeout(resolve, 100))
const finalCalls = getFetchCalls().length
expect(finalCalls).toBe(initialCalls)
})
})

View file

@ -0,0 +1,52 @@
import '@testing-library/jest-dom'
import { vi } from 'vitest'
// Mock fetch for API calls
globalThis.fetch = vi.fn()
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
// Suppress console.error in tests (optional, remove if you want to see errors)
const originalError = console.error
beforeAll(() => {
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
args[0].includes('Warning: ReactDOM.render is no longer supported')
) {
return
}
originalError.call(console, ...args)
}
})
afterAll(() => {
console.error = originalError
})

View file

@ -0,0 +1,161 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--font-sans: 'Inter', system-ui, sans-serif;
--font-display: 'Cal Sans', 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
html {
scroll-behavior: smooth;
}
body {
@apply font-sans text-dark-900 bg-white antialiased;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
::selection {
@apply bg-brand-500/30 text-brand-900;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-dark-100;
}
::-webkit-scrollbar-thumb {
@apply bg-brand-500/50 rounded-full hover:bg-brand-500;
}
}
@layer components {
.container {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
.btn {
@apply inline-flex items-center justify-center gap-2 px-6 py-3 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;
}
.btn-primary {
@apply btn bg-brand-500 text-white hover:bg-brand-600 focus-visible:ring-brand-500 shadow-lg shadow-brand-500/25;
}
.btn-secondary {
@apply btn bg-dark-100 text-dark-900 hover:bg-dark-200 focus-visible:ring-dark-500 border border-dark-200;
}
.btn-outline {
@apply btn border-2 border-brand-500 text-brand-500 hover:bg-brand-500 hover:text-white focus-visible:ring-brand-500;
}
.btn-ghost {
@apply btn text-dark-600 hover:text-dark-900 hover:bg-dark-100 focus-visible:ring-dark-500;
}
.card {
@apply bg-white rounded-2xl border border-dark-100 overflow-hidden transition-all duration-300 hover:shadow-xl hover:border-brand-200;
}
.card-hover {
@apply card hover:-translate-y-1 hover:shadow-glow;
}
.section {
@apply py-16 sm:py-24 lg:py-32;
}
.section-title {
@apply text-3xl sm:text-4xl lg:text-5xl font-display font-bold text-dark-900 tracking-tight;
}
.section-subtitle {
@apply mt-4 text-lg sm:text-xl text-dark-600 max-w-2xl;
}
.badge {
@apply inline-flex items-center px-3 py-1 rounded-full text-sm font-medium;
}
.badge-brand {
@apply badge bg-brand-100 text-brand-700;
}
.badge-dark {
@apply badge bg-dark-100 text-dark-700;
}
.input {
@apply w-full px-4 py-3 rounded-lg border border-dark-200 bg-white text-dark-900 placeholder-dark-400 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500;
}
.label {
@apply block text-sm font-medium text-dark-700 mb-1.5;
}
.link {
@apply text-brand-500 hover:text-brand-600 underline-offset-2 hover:underline transition-colors;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.animate-delay-100 {
animation-delay: 100ms;
}
.animate-delay-200 {
animation-delay: 200ms;
}
.animate-delay-300 {
animation-delay: 300ms;
}
.animate-delay-400 {
animation-delay: 400ms;
}
.animate-delay-500 {
animation-delay: 500ms;
}
.gradient-text {
@apply bg-gradient-to-r from-brand-500 to-brand-700 bg-clip-text text-transparent;
}
.glass {
@apply bg-white/80 backdrop-blur-lg border border-white/20;
}
.glass-dark {
@apply bg-dark-900/80 backdrop-blur-lg border border-dark-700/50;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skeleton {
@apply animate-pulse bg-dark-200;
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}

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'

59
web/src/hooks/useApi.ts Normal file
View file

@ -0,0 +1,59 @@
import { useState, useEffect, useCallback } from 'react'
import { ApiError } from '../services/api'
interface UseApiState<T> {
data: T | null
loading: boolean
error: ApiError | null
}
interface UseApiReturn<T> extends UseApiState<T> {
refetch: () => Promise<void>
}
export function useApi<T>(fetchFn: () => Promise<T>, deps: unknown[] = []): UseApiReturn<T> {
const [state, setState] = useState<UseApiState<T>>({
data: null,
loading: true,
error: null,
})
const fetchData = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }))
try {
const data = await fetchFn()
setState({ data, loading: false, error: null })
} catch (error) {
setState({ data: null, loading: false, error: error as ApiError })
}
}, [fetchFn])
useEffect(() => {
fetchData()
}, [fetchData, ...deps])
return { ...state, refetch: fetchData }
}
export function useApiMutation<T, Args extends unknown[]>(
mutationFn: (...args: Args) => Promise<T>
) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<ApiError | null>(null)
const mutate = useCallback(async (...args: Args): Promise<T | null> => {
setLoading(true)
setError(null)
try {
const result = await mutationFn(...args)
setLoading(false)
return result
} catch (err) {
setError(err as ApiError)
setLoading(false)
return null
}
}, [mutationFn])
return { mutate, loading, error }
}

13
web/src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './assets/styles/main.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

74
web/src/services/api.ts Normal file
View file

@ -0,0 +1,74 @@
import type { APIResponse, Skill, Project, Experience, Service, ContactForm, ContactSubmission } from '../types'
const API_BASE = '/api/v1'
class ApiError extends Error {
constructor(public code: string, message: string, public details?: string) {
super(message)
this.name = 'ApiError'
}
}
async function fetchAPI<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
})
const data: APIResponse<T> = await response.json()
if (!response.ok || !data.success) {
throw new ApiError(
data.error?.code || 'UNKNOWN_ERROR',
data.error?.message || 'An error occurred',
data.error?.details
)
}
return data.data as T
}
export const api = {
skills: {
getAll: (category?: string) =>
fetchAPI<Skill[]>(`/skills${category ? `?category=${category}` : ''}`),
getById: (id: string) =>
fetchAPI<Skill>(`/skills/${id}`),
},
projects: {
getAll: (category?: string, featured?: boolean) => {
const params = new URLSearchParams()
if (category) params.append('category', category)
if (featured) params.append('featured', 'true')
return fetchAPI<Project[]>(`/projects${params.toString() ? `?${params}` : ''}`)
},
getFeatured: () =>
fetchAPI<Project[]>('/projects/featured'),
getById: (id: string) =>
fetchAPI<Project>(`/projects/${id}`),
},
experience: {
getAll: () =>
fetchAPI<Experience[]>('/experience'),
},
services: {
getAll: () =>
fetchAPI<Service[]>('/services'),
},
contact: {
submit: (form: ContactForm) =>
fetchAPI<ContactSubmission>('/contact', {
method: 'POST',
body: JSON.stringify(form),
}),
},
health: {
check: () =>
fetchAPI<{ status: string; service: string; version: string }>('/health'),
},
}
export { ApiError }

87
web/src/types/index.ts Normal file
View file

@ -0,0 +1,87 @@
export interface Skill {
id: string
name: string
category: string
proficiency: number
icon?: string
order: number
created_at: string
updated_at: string
}
export interface Project {
id: string
title: string
description: string
short_desc: string
tech_stack: string[]
image_url?: string
demo_url?: string
repo_url?: string
category: string
featured: boolean
order: number
created_at: string
updated_at: string
}
export interface Experience {
id: string
company: string
role: string
location: string
start_date: string
end_date?: string
current: boolean
description: string
tech_stack: string[]
order: number
created_at: string
updated_at: string
}
export interface Service {
id: string
title: string
description: string
icon?: string
features: string[]
order: number
active: boolean
created_at: string
updated_at: string
}
export interface ContactForm {
name: string
email: string
subject: string
message: string
}
export interface ContactSubmission {
id: string
name: string
email: string
subject: string
message: string
status: string
created_at: string
updated_at: string
}
export interface APIResponse<T> {
success: boolean
data?: T
error?: {
code: string
message: string
details?: string
}
meta?: {
page: number
page_size: number
total: number
total_pages: number
}
}

72
web/src/utils/helpers.ts Normal file
View file

@ -0,0 +1,72 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(date: string | Date): string {
const d = new Date(date)
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
})
}
export function formatDateRange(start: string, end?: string | null, current = false): string {
const startDate = formatDate(start)
const endDate = current ? 'Present' : end ? formatDate(end) : ''
return endDate ? `${startDate} - ${endDate}` : startDate
}
export function getProficiencyLabel(level: number): string {
const labels = ['', 'Beginner', 'Intermediate', 'Advanced', 'Expert', 'Master']
return labels[level] || 'Unknown'
}
export function getProficiencyColor(level: number): string {
if (level <= 2) return 'bg-green-500'
if (level <= 3) return 'bg-blue-500'
if (level <= 4) return 'bg-yellow-500'
return 'bg-red-500'
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>
return (...args: Parameters<T>) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn(...args), delay)
}
}
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false
return (...args: Parameters<T>) => {
if (!inThrottle) {
fn(...args)
inThrottle = true
setTimeout(() => (inThrottle = false), limit)
}
}
}
export function generateId(): string {
return Math.random().toString(36).substring(2, 11)
}
export function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
export function scrollToSection(sectionId: string): void {
const element = document.getElementById(sectionId)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}

90
web/tailwind.config.js Normal file
View file

@ -0,0 +1,90 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
brand: {
50: '#fef7ee',
100: '#fdedd6',
200: '#fad7ac',
300: '#f6ba78',
400: '#f19341',
500: '#ed7315', // Primary orange - "go-bananas" energy
600: '#de5c0d',
700: '#b8430f',
800: '#933514',
900: '#772e13',
950: '#401307',
},
dark: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
display: ['Cal Sans', 'Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out',
'slide-up': 'slideUp 0.6s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'bounce-subtle': 'bounceSubtle 2s infinite',
'float': 'float 6s ease-in-out infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(20px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideDown: {
'0%': { opacity: '0', transform: 'translateY(-10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
bounceSubtle: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-5px)' },
},
float: {
'0%, 100%': { transform: 'translateY(0px)' },
'50%': { transform: 'translateY(-10px)' },
},
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
'mesh-gradient': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
},
boxShadow: {
'glow': '0 0 20px rgba(237, 115, 21, 0.3)',
'glow-lg': '0 0 40px rgba(237, 115, 21, 0.4)',
'inner-glow': 'inset 0 0 20px rgba(237, 115, 21, 0.1)',
},
},
},
plugins: [],
}

View file

@ -0,0 +1,82 @@
import { test, expect } from '@playwright/test'
test.describe('E2E - Infinite Loop Detection', () => {
test.beforeEach(async ({ page }) => {
// Track all network requests
const apiCalls: string[] = []
page.on('request', request => {
if (request.url().includes('/api/v1/')) {
apiCalls.push(`${request.method()} ${request.url()}`)
}
})
await page.goto('http://localhost:3000')
await page.waitForLoadState('networkidle')
})
test('should not make repeated API calls after initial load', async ({ page }) => {
const initialApiCalls = await page.evaluate(() => {
return (window as any).__apiCallCount || 0
})
// Wait for initial load
await page.waitForSelector('text=Services & Expertise')
await page.waitForSelector('text=Technical Skills')
// Wait a bit more to catch any delayed calls
await page.waitForTimeout(3000)
const finalApiCalls = await page.evaluate(() => {
return (window as any).__apiCallCount || 0
})
// Should not have made additional API calls
expect(finalApiCalls).toBeLessThanOrEqual(initialApiCalls + 1)
})
test('should load all sections without errors', async ({ page }) => {
await expect(page.locator('text=Services & Expertise')).toBeVisible()
await expect(page.locator('text=Selected Projects')).toBeVisible()
await expect(page.locator('text=Technical Skills')).toBeVisible()
await expect(page.locator('text=Experience')).toBeVisible()
await expect(page.locator('text=Let\\'s Build Something Great')).toBeVisible()
})
test('should handle navigation without duplicate fetches', async ({ page }) => {
// Scroll to trigger any lazy loading
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await page.waitForTimeout(1000)
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(1000)
// Check for console errors
const errors: string[] = []
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(msg.text())
}
})
await page.waitForTimeout(2000)
// Filter out known non-critical errors
const criticalErrors = errors.filter(e =>
!e.includes('favicon') &&
!e.includes('404') &&
!e.includes('DevTools')
)
expect(criticalErrors.length).toBe(0)
})
test('contact form submission works', async ({ page }) => {
await page.locator('input[name="name"]').fill('John Doe')
await page.locator('input[name="email"]').fill('john@example.com')
await page.locator('input[name="subject"]').fill('Test Subject')
await page.locator('textarea[name="message"]').fill('This is a test message for the contact form.')
await page.click('button:has-text("Send Message")')
await expect(page.locator('text=Message Sent!')).toBeVisible({ timeout: 10000 })
})
})

View file

@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test'
test.describe('Network Request Monitoring', () => {
test('tracks API calls to detect infinite loops', async ({ page }) => {
const apiCalls: { url: string; timestamp: number; count: number }[] = []
const urlCounts = new Map<string, number>()
page.on('request', request => {
const url = request.url()
if (url.includes('/api/v1/')) {
const count = (urlCounts.get(url) || 0) + 1
urlCounts.set(url, count)
apiCalls.push({ url, timestamp: Date.now(), count })
}
})
await page.goto('http://localhost:3000')
await page.waitForLoadState('networkidle')
await page.waitForSelector('text=Technical Skills')
// Wait for any potential delayed calls
await page.waitForTimeout(5000)
// Check for duplicate calls to the same endpoint
const duplicateCalls = Array.from(urlCounts.entries())
.filter(([_, count]) => count > 1)
.map(([url, count]) => ({ url, count }))
console.log('API Call counts:', Object.fromEntries(urlCounts))
console.log('Duplicate calls:', duplicateCalls)
// Allow 1 call per endpoint, but flag if any endpoint is called more than twice
const excessiveCalls = duplicateCalls.filter(d => d.count > 2)
expect(excessiveCalls.length).toBe(0)
})
test('monitors fetch calls for repeated patterns', async ({ page }) => {
await page.goto('http://localhost:3000')
await page.waitForLoadState('networkidle')
// Inject monitoring script
await page.addInitScript(() => {
const originalFetch = window.fetch
const callLog: { url: string; count: number }[] = []
window.fetch = async function(...args) {
const url = args[0]?.toString() || ''
if (url.includes('/api/v1/')) {
const existing = callLog.find(c => c.url === url)
if (existing) {
existing.count++
} else {
callLog.push({ url, count: 1 })
}
}
return originalFetch.apply(this, args)
}
;(window as any).__getApiCallLog = () => callLog
})
await page.waitForSelector('text=Technical Skills')
await page.waitForTimeout(3000)
const callLog = await page.evaluate(() => (window as any).__getApiCallLog())
console.log('Fetch call log:', callLog)
// Check for excessive calls
const excessive = callLog.filter((c: { count: number }) => c.count > 2)
expect(excessive.length).toBe(0)
})
})

26
web/tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

34
web/vite.config.ts Normal file
View file

@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: '../dist',
emptyOutDir: true,
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
ui: ['lucide-react'],
},
},
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8081',
changeOrigin: true,
},
},
},
})

27
web/vitest.config.ts Normal file
View file

@ -0,0 +1,27 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./src/__tests__/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
testTimeout: 30000,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/__tests__/', 'src/main.tsx', 'src/vite-env.d.ts'],
},
deps: {
inline: ['@testing-library/jest-dom'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})