initial commit
This commit is contained in:
commit
e7987e88a7
70 changed files with 13661 additions and 0 deletions
18
web/index.html
Normal file
18
web/index.html
Normal 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
6018
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
52
web/package.json
Normal file
52
web/package.json
Normal 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
6
web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
4
web/public/favicon.svg
Normal file
4
web/public/favicon.svg
Normal 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
8
web/public/grid.svg
Normal 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
27
web/src/App.tsx
Normal 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
|
||||
129
web/src/__tests__/App.test.tsx
Normal file
129
web/src/__tests__/App.test.tsx
Normal 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 })
|
||||
})
|
||||
})
|
||||
86
web/src/__tests__/Services.test.tsx
Normal file
86
web/src/__tests__/Services.test.tsx
Normal 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 })
|
||||
})
|
||||
})
|
||||
65
web/src/__tests__/api-mock.ts
Normal file
65
web/src/__tests__/api-mock.ts
Normal 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()
|
||||
}
|
||||
130
web/src/__tests__/performance.test.tsx
Normal file
130
web/src/__tests__/performance.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
52
web/src/__tests__/setup.ts
Normal file
52
web/src/__tests__/setup.ts
Normal 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
|
||||
})
|
||||
161
web/src/assets/styles/main.css
Normal file
161
web/src/assets/styles/main.css
Normal 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;
|
||||
}
|
||||
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'
|
||||
59
web/src/hooks/useApi.ts
Normal file
59
web/src/hooks/useApi.ts
Normal 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
13
web/src/main.tsx
Normal 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
74
web/src/services/api.ts
Normal 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
87
web/src/types/index.ts
Normal 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
72
web/src/utils/helpers.ts
Normal 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
90
web/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
82
web/tests/infinite-loop.spec.ts
Normal file
82
web/tests/infinite-loop.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
73
web/tests/network-monitoring.spec.ts
Normal file
73
web/tests/network-monitoring.spec.ts
Normal 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
26
web/tsconfig.json
Normal 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
11
web/tsconfig.node.json
Normal 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
34
web/vite.config.ts
Normal 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
27
web/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue